2
0

feat(pages): add A/B testing framework for landing pages

Implement comprehensive A/B testing system for landing page optimization:
- Database models for experiments, variants, and events
- AI-powered variant generation and analysis
- Visitor tracking with conversion metrics
- Experiment lifecycle management (draft/active/paused/completed)
- Email notifications for experiment results
- Cron job for automated experiment monitoring
- UI for viewing experiment results and statistics
This commit is contained in:
2026-03-07 12:39:42 -05:00
parent 64b4a9ceed
commit 3a8bdd936c
27 changed files with 1371 additions and 84 deletions

View File

@@ -440,6 +440,7 @@ func prepareMigrationTasks() []*migration {
newMigration(363, "Add keep_packages_private to user", v1_26.AddKeepPackagesPrivateToUser),
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),
}
return preparedMigrations
}

View File

@@ -0,0 +1,54 @@
// 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 AddPageExperimentTables(x *xorm.Engine) error {
type PageExperiment struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
Name string `xorm:"VARCHAR(255) NOT NULL"`
Status string `xorm:"VARCHAR(32) NOT NULL DEFAULT 'draft'"`
CreatedByAI bool `xorm:"NOT NULL DEFAULT true"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
EndsUnix timeutil.TimeStamp `xorm:"DEFAULT 0"`
}
type PageVariant struct {
ID int64 `xorm:"pk autoincr"`
ExperimentID int64 `xorm:"INDEX NOT NULL"`
Name string `xorm:"VARCHAR(255) NOT NULL"`
IsControl bool `xorm:"NOT NULL DEFAULT false"`
Weight int `xorm:"NOT NULL DEFAULT 50"`
ConfigOverride string `xorm:"TEXT"`
Impressions int64 `xorm:"NOT NULL DEFAULT 0"`
Conversions int64 `xorm:"NOT NULL DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
type PageEvent struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
VariantID int64 `xorm:"INDEX DEFAULT 0"`
ExperimentID int64 `xorm:"INDEX DEFAULT 0"`
VisitorID string `xorm:"VARCHAR(64) INDEX"`
EventType string `xorm:"VARCHAR(32) NOT NULL"`
EventData string `xorm:"TEXT"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
if err := x.Sync(new(PageExperiment)); err != nil {
return err
}
if err := x.Sync(new(PageVariant)); err != nil {
return err
}
return x.Sync(new(PageEvent))
}

272
models/pages/experiment.go Normal file
View File

@@ -0,0 +1,272 @@
// 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"
)
// ExperimentStatus represents the status of an A/B test experiment.
type ExperimentStatus string
const (
ExperimentStatusDraft ExperimentStatus = "draft"
ExperimentStatusActive ExperimentStatus = "active"
ExperimentStatusPaused ExperimentStatus = "paused"
ExperimentStatusCompleted ExperimentStatus = "completed"
ExperimentStatusApproved ExperimentStatus = "approved"
)
func init() {
db.RegisterModel(new(PageExperiment))
db.RegisterModel(new(PageVariant))
db.RegisterModel(new(PageEvent))
}
// PageExperiment tracks an A/B test on a landing page.
type PageExperiment struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
Name string `xorm:"VARCHAR(255) NOT NULL"`
Status ExperimentStatus `xorm:"VARCHAR(32) NOT NULL DEFAULT 'draft'"`
CreatedByAI bool `xorm:"NOT NULL DEFAULT true"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
EndsUnix timeutil.TimeStamp `xorm:"DEFAULT 0"`
Variants []*PageVariant `xorm:"-"`
}
// TableName returns the table name for PageExperiment.
func (e *PageExperiment) TableName() string {
return "page_experiment"
}
// PageVariant is one arm of an A/B test experiment.
type PageVariant struct {
ID int64 `xorm:"pk autoincr"`
ExperimentID int64 `xorm:"INDEX NOT NULL"`
Name string `xorm:"VARCHAR(255) NOT NULL"`
IsControl bool `xorm:"NOT NULL DEFAULT false"`
Weight int `xorm:"NOT NULL DEFAULT 50"`
ConfigOverride string `xorm:"TEXT"`
Impressions int64 `xorm:"NOT NULL DEFAULT 0"`
Conversions int64 `xorm:"NOT NULL DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
// TableName returns the table name for PageVariant.
func (v *PageVariant) TableName() string {
return "page_variant"
}
// ConversionRate returns the conversion rate for this variant.
func (v *PageVariant) ConversionRate() float64 {
if v.Impressions == 0 {
return 0
}
return float64(v.Conversions) / float64(v.Impressions)
}
// PageEvent tracks visitor interactions with a landing page.
type PageEvent struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX NOT NULL"`
VariantID int64 `xorm:"INDEX DEFAULT 0"`
ExperimentID int64 `xorm:"INDEX DEFAULT 0"`
VisitorID string `xorm:"VARCHAR(64) INDEX"`
EventType string `xorm:"VARCHAR(32) NOT NULL"`
EventData string `xorm:"TEXT"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
}
// TableName returns the table name for PageEvent.
func (e *PageEvent) TableName() string {
return "page_event"
}
// Valid event types
const (
EventTypeImpression = "impression"
EventTypeCTAClick = "cta_click"
EventTypeScrollDepth = "scroll_depth"
EventTypeClick = "click"
EventTypeStar = "star"
EventTypeFork = "fork"
EventTypeClone = "clone"
)
// CreateExperiment creates a new experiment.
func CreateExperiment(ctx context.Context, exp *PageExperiment) error {
_, err := db.GetEngine(ctx).Insert(exp)
return err
}
// GetExperimentByID returns an experiment by ID.
func GetExperimentByID(ctx context.Context, id int64) (*PageExperiment, error) {
exp := new(PageExperiment)
has, err := db.GetEngine(ctx).ID(id).Get(exp)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return exp, nil
}
// GetExperimentsByRepoID returns all experiments for a repository.
func GetExperimentsByRepoID(ctx context.Context, repoID int64) ([]*PageExperiment, error) {
experiments := make([]*PageExperiment, 0, 10)
return experiments, db.GetEngine(ctx).Where("repo_id = ?", repoID).
Desc("created_unix").Find(&experiments)
}
// GetActiveExperimentByRepoID returns the currently active experiment for a repo, if any.
func GetActiveExperimentByRepoID(ctx context.Context, repoID int64) (*PageExperiment, error) {
exp := new(PageExperiment)
has, err := db.GetEngine(ctx).
Where("repo_id = ? AND status = ?", repoID, ExperimentStatusActive).
Get(exp)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return exp, nil
}
// GetAllActiveExperiments returns all active experiments across all repos.
func GetAllActiveExperiments(ctx context.Context) ([]*PageExperiment, error) {
experiments := make([]*PageExperiment, 0, 50)
return experiments, db.GetEngine(ctx).
Where("status = ?", ExperimentStatusActive).
Find(&experiments)
}
// UpdateExperiment updates an experiment.
func UpdateExperiment(ctx context.Context, exp *PageExperiment) error {
_, err := db.GetEngine(ctx).ID(exp.ID).AllCols().Update(exp)
return err
}
// UpdateExperimentStatus updates just the status of an experiment.
func UpdateExperimentStatus(ctx context.Context, id int64, status ExperimentStatus) error {
_, err := db.GetEngine(ctx).ID(id).Cols("status").
Update(&PageExperiment{Status: status})
return err
}
// CreateVariant creates a new variant for an experiment.
func CreateVariant(ctx context.Context, variant *PageVariant) error {
_, err := db.GetEngine(ctx).Insert(variant)
return err
}
// GetVariantByID returns a variant by ID.
func GetVariantByID(ctx context.Context, id int64) (*PageVariant, error) {
variant := new(PageVariant)
has, err := db.GetEngine(ctx).ID(id).Get(variant)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return variant, nil
}
// GetVariantsByExperimentID returns all variants for an experiment.
func GetVariantsByExperimentID(ctx context.Context, experimentID int64) ([]*PageVariant, error) {
variants := make([]*PageVariant, 0, 5)
return variants, db.GetEngine(ctx).
Where("experiment_id = ?", experimentID).
Find(&variants)
}
// IncrementVariantImpressions increments the impression counter for a variant.
func IncrementVariantImpressions(ctx context.Context, variantID int64) error {
_, err := db.GetEngine(ctx).Exec(
"UPDATE `page_variant` SET impressions = impressions + 1 WHERE id = ?", variantID)
return err
}
// IncrementVariantConversions increments the conversion counter for a variant.
func IncrementVariantConversions(ctx context.Context, variantID int64) error {
_, err := db.GetEngine(ctx).Exec(
"UPDATE `page_variant` SET conversions = conversions + 1 WHERE id = ?", variantID)
return err
}
// CreatePageEvent records a visitor event.
func CreatePageEvent(ctx context.Context, event *PageEvent) error {
_, err := db.GetEngine(ctx).Insert(event)
return err
}
// GetEventCountByVariant returns event counts grouped by event type for a variant.
func GetEventCountByVariant(ctx context.Context, variantID int64) (map[string]int64, error) {
type countResult struct {
EventType string `xorm:"event_type"`
Count int64 `xorm:"cnt"`
}
results := make([]countResult, 0)
err := db.GetEngine(ctx).Table("page_event").
Select("event_type, COUNT(*) as cnt").
Where("variant_id = ?", variantID).
GroupBy("event_type").
Find(&results)
if err != nil {
return nil, err
}
counts := make(map[string]int64, len(results))
for _, r := range results {
counts[r.EventType] = r.Count
}
return counts, nil
}
// GetEventCountsByExperiment returns event counts for all variants in an experiment.
func GetEventCountsByExperiment(ctx context.Context, experimentID int64) (map[int64]map[string]int64, error) {
type countResult struct {
VariantID int64 `xorm:"variant_id"`
EventType string `xorm:"event_type"`
Count int64 `xorm:"cnt"`
}
results := make([]countResult, 0)
err := db.GetEngine(ctx).Table("page_event").
Select("variant_id, event_type, COUNT(*) as cnt").
Where("experiment_id = ?", experimentID).
GroupBy("variant_id, event_type").
Find(&results)
if err != nil {
return nil, err
}
counts := make(map[int64]map[string]int64)
for _, r := range results {
if counts[r.VariantID] == nil {
counts[r.VariantID] = make(map[string]int64)
}
counts[r.VariantID][r.EventType] = r.Count
}
return counts, nil
}
// RecordRepoAction records a repo action (star, fork, clone) as a page event
// if the repo has an active experiment.
func RecordRepoAction(ctx context.Context, repoID int64, eventType string) {
exp, err := GetActiveExperimentByRepoID(ctx, repoID)
if err != nil || exp == nil {
return
}
_ = CreatePageEvent(ctx, &PageEvent{
RepoID: repoID,
ExperimentID: exp.ID,
EventType: eventType,
})
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"code.gitcaddy.com/server/v3/models/db"
pages_model "code.gitcaddy.com/server/v3/models/pages"
user_model "code.gitcaddy.com/server/v3/models/user"
"code.gitcaddy.com/server/v3/modules/timeutil"
)
@@ -25,7 +26,7 @@ func init() {
// StarRepo or unstar repository.
func StarRepo(ctx context.Context, doer *user_model.User, repo *Repository, star bool) error {
return db.WithTx(ctx, func(ctx context.Context) error {
err := db.WithTx(ctx, func(ctx context.Context) error {
staring := IsStaring(ctx, doer.ID, repo.ID)
if star {
@@ -64,6 +65,10 @@ func StarRepo(ctx context.Context, doer *user_model.User, repo *Repository, star
return nil
})
if err == nil && star {
pages_model.RecordRepoAction(ctx, repo.ID, pages_model.EventTypeStar)
}
return err
}
// IsStaring checks if user has starred given repository.

View File

@@ -213,6 +213,20 @@ func (c *Client) InspectWorkflow(ctx context.Context, req *InspectWorkflowReques
return resp, nil
}
// ExecuteTask executes a generic AI task via the sidecar
func (c *Client) ExecuteTask(ctx context.Context, req *ExecuteTaskRequest) (*ExecuteTaskResponse, error) {
if !IsEnabled() {
return nil, errors.New("AI service is not enabled")
}
var resp ExecuteTaskResponse
if err := c.doRequest(ctx, http.MethodPost, "/execute-task", req, &resp); err != nil {
log.Error("AI ExecuteTask failed: %v", err)
return nil, err
}
return &resp, nil
}
// CheckHealth checks the health of the AI service
func (c *Client) CheckHealth(ctx context.Context) (*HealthCheckResponse, error) {
var resp HealthCheckResponse

View File

@@ -258,6 +258,22 @@ type InspectWorkflowResponse struct {
OutputTokens int `json:"output_tokens"`
}
// ExecuteTaskRequest is the request for executing a generic AI task
type ExecuteTaskRequest struct {
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
RepoID int64 `json:"repo_id"`
Task string `json:"task"`
Context map[string]string `json:"context"`
AllowedTools []string `json:"allowed_tools,omitempty"`
}
// ExecuteTaskResponse is the response from executing a generic AI task
type ExecuteTaskResponse struct {
Success bool `json:"success"`
Result string `json:"result"`
Error string `json:"error,omitempty"`
}
// HealthCheckResponse is the response from a health check
type HealthCheckResponse struct {
Healthy bool `json:"healthy"`

View File

@@ -47,6 +47,9 @@ type LandingConfig struct {
// Blog section
Blog BlogSectionConfig `yaml:"blog,omitempty"`
// Navigation visibility
Navigation NavigationConfig `yaml:"navigation,omitempty"`
// Footer
Footer FooterConfig `yaml:"footer,omitempty"`
@@ -61,6 +64,9 @@ type LandingConfig struct {
// Advanced settings
Advanced AdvancedConfig `yaml:"advanced,omitempty"`
// A/B testing experiments
Experiments ExperimentConfig `yaml:"experiments,omitempty"`
}
// BrandConfig represents brand/identity settings
@@ -69,6 +75,7 @@ type BrandConfig struct {
LogoURL string `yaml:"logo_url,omitempty"`
LogoSource string `yaml:"logo_source,omitempty"` // "url" (default), "repo", or "org" — selects avatar source
Tagline string `yaml:"tagline,omitempty"`
FaviconURL string `yaml:"favicon_url,omitempty"`
}
// HeroConfig represents hero section settings
@@ -159,6 +166,15 @@ type BlogSectionConfig struct {
CTAButton CTAButton `yaml:"cta_button,omitempty"` // "View All Posts" link
}
// NavigationConfig controls which built-in navigation links appear in the header and footer
type NavigationConfig struct {
ShowDocs bool `yaml:"show_docs,omitempty"`
ShowAPI bool `yaml:"show_api,omitempty"`
ShowRepository bool `yaml:"show_repository,omitempty"`
ShowReleases bool `yaml:"show_releases,omitempty"`
ShowIssues bool `yaml:"show_issues,omitempty"`
}
// FooterConfig represents footer settings
type FooterConfig struct {
Links []FooterLink `yaml:"links,omitempty"`
@@ -209,6 +225,14 @@ type UmamiConfig struct {
URL string `yaml:"url,omitempty"`
}
// ExperimentConfig represents A/B testing experiment settings
type ExperimentConfig struct {
Enabled bool `yaml:"enabled,omitempty"`
AutoOptimize bool `yaml:"auto_optimize,omitempty"`
MinImpressions int `yaml:"min_impressions,omitempty"`
ApprovalRequired bool `yaml:"approval_required,omitempty"`
}
// AdvancedConfig represents advanced settings
type AdvancedConfig struct {
CustomCSS string `yaml:"custom_css,omitempty"`
@@ -272,6 +296,11 @@ func DefaultConfig() *LandingConfig {
{Title: "Flexible", Description: "Adapts to your workflow, not the other way around.", Icon: "gear"},
{Title: "Open Source", Description: "Free forever. Community driven.", Icon: "heart"},
},
Navigation: NavigationConfig{
ShowDocs: true,
ShowRepository: true,
ShowReleases: true,
},
CTASection: CTASectionConfig{
Headline: "Ready to get started?",
Subheadline: "Join thousands of developers already using this project.",

View File

@@ -4493,6 +4493,15 @@
"repo.settings.pages.seo_description": "Meta Description",
"repo.settings.pages.seo_keywords": "Keywords",
"repo.settings.pages.og_image": "Open Graph Image URL",
"repo.settings.pages.brand_favicon_url": "Favicon URL",
"repo.settings.pages.brand_favicon_url_help": "URL to a custom favicon for your landing page (ICO, PNG, or SVG). Leave blank to use the default.",
"repo.settings.pages.navigation": "Navigation Links",
"repo.settings.pages.navigation_desc": "Control which built-in links appear in the header and footer navigation.",
"repo.settings.pages.nav_show_docs": "Show Docs link (links to wiki)",
"repo.settings.pages.nav_show_api": "Show API link (links to Swagger docs)",
"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.vault": "Vault",
"repo.vault.secrets": "Secrets",
"repo.vault.new_secret": "New Secret",

View File

@@ -4,9 +4,13 @@
package pages
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"html/template"
"io"
"math/big"
"net/http"
"path"
"strconv"
@@ -14,9 +18,11 @@ import (
"time"
blog_model "code.gitcaddy.com/server/v3/models/blog"
pages_model "code.gitcaddy.com/server/v3/models/pages"
"code.gitcaddy.com/server/v3/models/renderhelper"
repo_model "code.gitcaddy.com/server/v3/models/repo"
"code.gitcaddy.com/server/v3/modules/git"
"code.gitcaddy.com/server/v3/modules/json"
"code.gitcaddy.com/server/v3/modules/log"
"code.gitcaddy.com/server/v3/modules/markup/markdown"
pages_module "code.gitcaddy.com/server/v3/modules/pages"
@@ -56,6 +62,12 @@ func ServeLandingPage(ctx *context.Context) {
}
}
// Handle event tracking POST
if ctx.Req.Method == http.MethodPost && (requestPath == "/pages/events" || requestPath == "/pages/events/") {
servePageEvent(ctx, repo)
return
}
// Check for blog paths on custom domain
if config.Blog.Enabled && repo.BlogEnabled {
if idStr, found := strings.CutPrefix(requestPath, "/blog/"); found {
@@ -71,8 +83,9 @@ func ServeLandingPage(ctx *context.Context) {
}
}
// Render the landing page
// Render the landing page with A/B test variant
ctx.Data["BlogBaseURL"] = "/blog"
config = assignVariant(ctx, repo, config)
renderLandingPage(ctx, repo, config)
}
@@ -473,6 +486,7 @@ func ServeRepoLandingPage(ctx *context.Context) {
}
ctx.Data["BlogBaseURL"] = fmt.Sprintf("/%s/%s/pages/blog", repo.OwnerName, repo.Name)
config = assignVariant(ctx, repo, config)
renderLandingPage(ctx, repo, config)
}
@@ -599,6 +613,277 @@ func ServeRepoPageAsset(ctx *context.Context) {
_, _ = ctx.Resp.Write(content)
}
// servePageEvent handles POST /pages/events for A/B test event tracking
func servePageEvent(ctx *context.Context, repo *repo_model.Repository) {
body, err := io.ReadAll(io.LimitReader(ctx.Req.Body, 4096))
if err != nil {
ctx.Status(http.StatusBadRequest)
return
}
var payload struct {
EventType string `json:"event_type"`
VariantID int64 `json:"variant_id"`
ExperimentID int64 `json:"experiment_id"`
VisitorID string `json:"visitor_id"`
Data string `json:"data"`
}
if err := json.Unmarshal(body, &payload); err != nil {
ctx.Status(http.StatusBadRequest)
return
}
// Validate event type
validTypes := map[string]bool{
pages_model.EventTypeImpression: true,
pages_model.EventTypeCTAClick: true,
pages_model.EventTypeScrollDepth: true,
pages_model.EventTypeClick: true,
}
if !validTypes[payload.EventType] {
ctx.Status(http.StatusBadRequest)
return
}
// Record event
_ = pages_model.CreatePageEvent(ctx, &pages_model.PageEvent{
RepoID: repo.ID,
VariantID: payload.VariantID,
ExperimentID: payload.ExperimentID,
VisitorID: payload.VisitorID,
EventType: payload.EventType,
EventData: payload.Data,
})
// Update denormalized counters
if payload.VariantID > 0 {
switch payload.EventType {
case pages_model.EventTypeImpression:
_ = pages_model.IncrementVariantImpressions(ctx, payload.VariantID)
case pages_model.EventTypeCTAClick:
_ = pages_model.IncrementVariantConversions(ctx, payload.VariantID)
}
}
ctx.Status(http.StatusNoContent)
}
// ServePageEvent handles POST event tracking via path-based routes
func ServePageEvent(ctx *context.Context) {
repo := ctx.Repo.Repository
if repo == nil {
ctx.Status(http.StatusNotFound)
return
}
servePageEvent(ctx, repo)
}
// assignVariant checks for an active experiment, assigns the visitor to a variant,
// and deep-merges the variant's config overrides onto the base config.
func assignVariant(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) *pages_module.LandingConfig {
if !config.Experiments.Enabled {
return config
}
exp, err := pages_model.GetActiveExperimentByRepoID(ctx, repo.ID)
if err != nil || exp == nil {
return config
}
// Ensure visitor ID cookie
visitorID := ctx.GetSiteCookie("pgvid")
if visitorID == "" {
visitorID = generateVisitorID()
ctx.SetSiteCookie("pgvid", visitorID, 86400*30) // 30 days
}
// Check existing variant assignment
cookieName := fmt.Sprintf("pgvar_%d", exp.ID)
variantIDStr := ctx.GetSiteCookie(cookieName)
var variant *pages_model.PageVariant
if variantIDStr != "" {
variantID, parseErr := strconv.ParseInt(variantIDStr, 10, 64)
if parseErr == nil {
variant, _ = pages_model.GetVariantByID(ctx, variantID)
}
}
if variant == nil {
// Load all variants and do weighted random assignment
variants, loadErr := pages_model.GetVariantsByExperimentID(ctx, exp.ID)
if loadErr != nil || len(variants) == 0 {
return config
}
variant = weightedRandomSelect(variants)
ctx.SetSiteCookie(cookieName, strconv.FormatInt(variant.ID, 10), 86400*30)
}
// Set A/B test template data
ctx.Data["ABTestActive"] = true
ctx.Data["ExperimentID"] = exp.ID
ctx.Data["VariantID"] = variant.ID
ctx.Data["VisitorID"] = visitorID
// Determine event tracking URL
if blogBaseURL, ok := ctx.Data["BlogBaseURL"].(string); ok && strings.Contains(blogBaseURL, "/pages/blog") {
// Path-based: use repo-scoped events URL
ctx.Data["EventTrackURL"] = strings.TrimSuffix(blogBaseURL, "/blog") + "/events"
} else {
ctx.Data["EventTrackURL"] = "/pages/events"
}
// If control variant, no config overrides needed
if variant.IsControl || variant.ConfigOverride == "" {
return config
}
// Deep-merge variant overrides onto config
merged, mergeErr := deepMergeConfig(config, variant.ConfigOverride)
if mergeErr != nil {
log.Error("Failed to merge variant config: %v", mergeErr)
return config
}
return merged
}
// generateVisitorID creates a random 16-byte hex visitor identifier.
func generateVisitorID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// weightedRandomSelect picks a variant using weighted random selection.
func weightedRandomSelect(variants []*pages_model.PageVariant) *pages_model.PageVariant {
totalWeight := 0
for _, v := range variants {
totalWeight += v.Weight
}
if totalWeight <= 0 {
return variants[0]
}
n, _ := rand.Int(rand.Reader, big.NewInt(int64(totalWeight)))
pick := int(n.Int64())
cumulative := 0
for _, v := range variants {
cumulative += v.Weight
if pick < cumulative {
return v
}
}
return variants[len(variants)-1]
}
// deepMergeConfig deep-merges a JSON config override onto a base LandingConfig.
// Only non-zero values in the override replace base values.
func deepMergeConfig(base *pages_module.LandingConfig, overrideJSON string) (*pages_module.LandingConfig, error) {
// Marshal base to JSON map
baseJSON, err := json.Marshal(base)
if err != nil {
return nil, err
}
var baseMap map[string]any
if err := json.Unmarshal(baseJSON, &baseMap); err != nil {
return nil, err
}
var overrideMap map[string]any
if err := json.Unmarshal([]byte(overrideJSON), &overrideMap); err != nil {
return nil, err
}
// Deep merge
merged := deepMerge(baseMap, overrideMap)
// Unmarshal back to LandingConfig
mergedJSON, err := json.Marshal(merged)
if err != nil {
return nil, err
}
var result pages_module.LandingConfig
if err := json.Unmarshal(mergedJSON, &result); err != nil {
return nil, err
}
return &result, nil
}
// deepMerge recursively merges src into dst.
func deepMerge(dst, src map[string]any) map[string]any {
for key, srcVal := range src {
if dstVal, ok := dst[key]; ok {
// Both are maps: recurse
srcMap, srcIsMap := srcVal.(map[string]any)
dstMap, dstIsMap := dstVal.(map[string]any)
if srcIsMap && dstIsMap {
dst[key] = deepMerge(dstMap, srcMap)
continue
}
}
dst[key] = srcVal
}
return dst
}
// ApproveExperiment handles the email approval link for an A/B test experiment
func ApproveExperiment(ctx *context.Context) {
handleExperimentAction(ctx, true)
}
// DeclineExperiment handles the email decline link for an A/B test experiment
func DeclineExperiment(ctx *context.Context) {
handleExperimentAction(ctx, false)
}
func handleExperimentAction(ctx *context.Context, approve bool) {
tokenStr := ctx.PathParam("token")
if tokenStr == "" {
ctx.NotFound(errors.New("missing token"))
return
}
// Extract and verify the token
expIDStr, err := pages_service.VerifyExperimentToken(ctx, tokenStr)
if err != nil {
log.Error("Invalid experiment token: %v", err)
ctx.NotFound(errors.New("invalid or expired token"))
return
}
expID, err := strconv.ParseInt(expIDStr, 10, 64)
if err != nil {
ctx.NotFound(errors.New("invalid experiment ID"))
return
}
exp, err := pages_model.GetExperimentByID(ctx, expID)
if err != nil || exp == nil {
ctx.NotFound(errors.New("experiment not found"))
return
}
if approve {
err = pages_model.UpdateExperimentStatus(ctx, exp.ID, pages_model.ExperimentStatusApproved)
ctx.Data["Title"] = "Experiment Approved"
ctx.Data["ExperimentApproved"] = true
} else {
err = pages_model.UpdateExperimentStatus(ctx, exp.ID, pages_model.ExperimentStatusPaused)
ctx.Data["Title"] = "Experiment Declined"
ctx.Data["ExperimentDeclined"] = true
}
if err != nil {
ctx.ServerError("Failed to update experiment", err)
return
}
ctx.Data["ExperimentName"] = exp.Name
ctx.HTML(http.StatusOK, "pages/experiment_result")
}
// getContentType returns the content type for a file extension
func getContentType(ext string) string {
types := map[string]string{

View File

@@ -213,6 +213,7 @@ func PagesBrandPost(ctx *context.Context) {
config.Brand.Name = ctx.FormString("brand_name")
config.Brand.LogoURL = ctx.FormString("brand_logo_url")
config.Brand.Tagline = ctx.FormString("brand_tagline")
config.Brand.FaviconURL = ctx.FormString("brand_favicon_url")
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
@@ -261,6 +262,11 @@ func PagesContent(ctx *context.Context) {
func PagesContentPost(ctx *context.Context) {
config := getPagesLandingConfig(ctx)
config.Advanced.PublicReleases = ctx.FormBool("public_releases")
config.Navigation.ShowDocs = ctx.FormBool("nav_show_docs")
config.Navigation.ShowAPI = ctx.FormBool("nav_show_api")
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.Stats = nil
for i := range 10 {
value := ctx.FormString(fmt.Sprintf("stat_value_%d", i))

View File

@@ -550,6 +550,10 @@ func registerWebRoutes(m *web.Router) {
m.Post("/-/markup", reqSignIn, web.Bind(structs.MarkupOption{}), misc.Markup)
// A/B test experiment approval (email links, no auth required — token-verified)
m.Get("/-/pages/experiment/approve/{token}", pages.ApproveExperiment)
m.Get("/-/pages/experiment/decline/{token}", pages.DeclineExperiment)
m.Get("/-/web-theme/list", misc.WebThemeList)
m.Post("/-/web-theme/apply", optSignIn, misc.WebThemeApply)
@@ -1794,6 +1798,7 @@ func registerWebRoutes(m *web.Router) {
m.Get("", pages.ServeRepoLandingPage)
m.Get("/blog", pages.ServeRepoBlogList)
m.Get("/blog/{id}", pages.ServeRepoBlogDetail)
m.Post("/events", pages.ServePageEvent)
m.Get("/assets/*", pages.ServeRepoPageAsset)
}, optSignIn, context.RepoAssignment, func(ctx *context.Context) {
ctx.Data["PageIsPagesLanding"] = true

View File

@@ -16,6 +16,7 @@ import (
"code.gitcaddy.com/server/v3/modules/updatechecker"
asymkey_service "code.gitcaddy.com/server/v3/services/asymkey"
attachment_service "code.gitcaddy.com/server/v3/services/attachment"
pages_service "code.gitcaddy.com/server/v3/services/pages"
repo_service "code.gitcaddy.com/server/v3/services/repository"
archiver_service "code.gitcaddy.com/server/v3/services/repository/archiver"
user_service "code.gitcaddy.com/server/v3/services/user"
@@ -235,6 +236,16 @@ func registerCleanupExpiredUploadSessions() {
})
}
func registerAnalyzePageExperiments() {
RegisterTaskFatal("analyze_page_experiments", &BaseConfig{
Enabled: true,
RunAtStart: false,
Schedule: "@every 6h",
}, func(ctx context.Context, _ *user_model.User, _ Config) error {
return pages_service.AnalyzeAllActiveExperiments(ctx)
})
}
func initExtendedTasks() {
registerDeleteInactiveUsers()
registerDeleteRepositoryArchives()
@@ -251,4 +262,5 @@ func initExtendedTasks() {
registerGCLFS()
registerRebuildIssueIndexer()
registerCleanupExpiredUploadSessions()
registerAnalyzePageExperiments()
}

View File

@@ -0,0 +1,70 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package mailer
import (
"bytes"
"fmt"
pages_model "code.gitcaddy.com/server/v3/models/pages"
repo_model "code.gitcaddy.com/server/v3/models/repo"
user_model "code.gitcaddy.com/server/v3/models/user"
"code.gitcaddy.com/server/v3/modules/log"
"code.gitcaddy.com/server/v3/modules/setting"
sender_service "code.gitcaddy.com/server/v3/services/mailer/sender"
)
// SendExperimentApprovalEmail sends an email to the repo owner with approve/decline links
// for an A/B test experiment that has found a winning variant.
func SendExperimentApprovalEmail(owner *user_model.User, repo *repo_model.Repository,
exp *pages_model.PageExperiment, winnerVariant *pages_model.PageVariant,
approveToken, declineToken, summary string,
) {
if setting.MailService == nil {
log.Warn("Mail service not configured, cannot send experiment approval email")
return
}
if owner.Email == "" {
log.Warn("Repo owner %s has no email, cannot send experiment approval", owner.Name)
return
}
subject := fmt.Sprintf("[%s] A/B Test Results: %s", setting.AppName, exp.Name)
approveURL := fmt.Sprintf("%s-/pages/experiment/approve/%s", setting.AppURL, approveToken)
declineURL := fmt.Sprintf("%s-/pages/experiment/decline/%s", setting.AppURL, declineToken)
var body bytes.Buffer
body.WriteString("A/B Test Experiment Results\n")
body.WriteString("===========================\n\n")
body.WriteString(fmt.Sprintf("Repository: %s/%s\n", repo.OwnerName, repo.Name))
body.WriteString(fmt.Sprintf("Experiment: %s\n\n", exp.Name))
body.WriteString("Results Summary\n")
body.WriteString("---------------\n")
body.WriteString(summary + "\n\n")
if winnerVariant != nil {
body.WriteString(fmt.Sprintf("Winner: %s\n", winnerVariant.Name))
body.WriteString(fmt.Sprintf("Impressions: %d\n", winnerVariant.Impressions))
body.WriteString(fmt.Sprintf("Conversions: %d\n", winnerVariant.Conversions))
body.WriteString(fmt.Sprintf("Conversion Rate: %.2f%%\n\n", winnerVariant.ConversionRate()*100))
}
body.WriteString("Actions\n")
body.WriteString("-------\n")
body.WriteString(fmt.Sprintf("Approve (apply winning variant): %s\n\n", approveURL))
body.WriteString(fmt.Sprintf("Decline (keep current config): %s\n\n", declineURL))
body.WriteString("This link will expire in 7 days.\n")
body.WriteString(fmt.Sprintf("\n--\n%s\n%s\n", setting.AppName, setting.AppURL))
msg := sender_service.NewMessage(owner.Email, subject, body.String())
msg.Info = fmt.Sprintf("Experiment approval for %s/%s: %s", repo.OwnerName, repo.Name, exp.Name)
if err := sender_service.Send(sender, msg); err != nil {
log.Error("Failed to send experiment approval email: %v", err)
}
}

194
services/pages/analysis.go Normal file
View File

@@ -0,0 +1,194 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package pages
import (
"context"
"errors"
"fmt"
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/json"
"code.gitcaddy.com/server/v3/modules/log"
)
// AnalysisResult holds the AI's analysis of an experiment.
type AnalysisResult struct {
Status string `json:"status"` // "winner", "needs_more_data", "no_difference"
WinnerVariantID int64 `json:"winner_variant_id"`
Confidence float64 `json:"confidence"`
Summary string `json:"summary"`
Recommendation string `json:"recommendation"`
}
// AnalyzeExperiment uses the AI sidecar to evaluate experiment results.
func AnalyzeExperiment(ctx context.Context, exp *pages_model.PageExperiment) (*AnalysisResult, error) {
if !ai.IsEnabled() {
return nil, errors.New("AI service is not enabled")
}
// Load variants
variants, err := pages_model.GetVariantsByExperimentID(ctx, exp.ID)
if err != nil {
return nil, fmt.Errorf("failed to load variants: %w", err)
}
// Load event counts by variant
eventCounts, err := pages_model.GetEventCountsByExperiment(ctx, exp.ID)
if err != nil {
return nil, fmt.Errorf("failed to load event counts: %w", err)
}
// Build summary data
type variantSummary struct {
ID int64 `json:"id"`
Name string `json:"name"`
IsControl bool `json:"is_control"`
Impressions int64 `json:"impressions"`
Conversions int64 `json:"conversions"`
ConversionRate float64 `json:"conversion_rate"`
Events map[string]int64 `json:"events"`
}
summaries := make([]variantSummary, 0, len(variants))
for _, v := range variants {
vs := variantSummary{
ID: v.ID,
Name: v.Name,
IsControl: v.IsControl,
Impressions: v.Impressions,
Conversions: v.Conversions,
ConversionRate: v.ConversionRate(),
Events: eventCounts[v.ID],
}
summaries = append(summaries, vs)
}
variantsJSON, _ := json.Marshal(summaries)
experimentJSON, _ := json.Marshal(map[string]any{
"id": exp.ID,
"name": exp.Name,
"created_at": exp.CreatedUnix,
})
client := ai.GetClient()
resp, err := client.ExecuteTask(ctx, &ai.ExecuteTaskRequest{
RepoID: exp.RepoID,
Task: "ab_test_analyze",
Context: map[string]string{
"experiment": string(experimentJSON),
"variants": string(variantsJSON),
"instruction": `Analyze these A/B test results. Look at conversion rates,
impression counts, and event distributions across variants.
Determine if there is a statistically significant winner.
Return valid JSON:
{
"status": "winner" or "needs_more_data" or "no_difference",
"winner_variant_id": <ID of winning variant, or 0>,
"confidence": <0.0 to 1.0>,
"summary": "Brief human-readable summary of results",
"recommendation": "What to do next"
}
Require at least 100 impressions per variant before declaring a winner.
Use a minimum 95% confidence threshold.`,
},
})
if err != nil {
return nil, fmt.Errorf("AI analysis failed: %w", err)
}
if !resp.Success {
return nil, fmt.Errorf("AI analysis error: %s", resp.Error)
}
var result AnalysisResult
if err := json.Unmarshal([]byte(resp.Result), &result); err != nil {
return nil, fmt.Errorf("failed to parse AI analysis: %w", err)
}
// If winner found, update experiment status
if result.Status == "winner" {
if err := pages_model.UpdateExperimentStatus(ctx, exp.ID, pages_model.ExperimentStatusCompleted); err != nil {
log.Error("Failed to update experiment status: %v", err)
}
}
return &result, nil
}
// AnalyzeAllActiveExperiments analyzes all active experiments that have enough data.
func AnalyzeAllActiveExperiments(ctx context.Context) error {
experiments, err := pages_model.GetAllActiveExperiments(ctx)
if err != nil {
return fmt.Errorf("failed to load active experiments: %w", err)
}
for _, exp := range experiments {
// Load variants to check if we have enough data
variants, err := pages_model.GetVariantsByExperimentID(ctx, exp.ID)
if err != nil {
log.Error("Failed to load variants for experiment %d: %v", exp.ID, err)
continue
}
// Determine minimum impressions threshold
repo, err := repo_model.GetRepositoryByID(ctx, exp.RepoID)
if err != nil {
log.Error("Failed to load repo for experiment %d: %v", exp.ID, err)
continue
}
config, err := GetPagesConfig(ctx, repo)
if err != nil {
log.Error("Failed to load config for experiment %d: %v", exp.ID, err)
continue
}
minImpressions := int64(config.Experiments.MinImpressions)
if minImpressions <= 0 {
minImpressions = 100
}
// Check if all variants have enough impressions
hasEnoughData := true
for _, v := range variants {
if v.Impressions < minImpressions {
hasEnoughData = false
break
}
}
if !hasEnoughData {
continue
}
result, err := AnalyzeExperiment(ctx, exp)
if err != nil {
log.Error("Failed to analyze experiment %d: %v", exp.ID, err)
continue
}
if result.Status == "winner" && result.WinnerVariantID > 0 {
log.Info("Experiment %d (%s): winner found (variant %d), confidence %.2f",
exp.ID, exp.Name, result.WinnerVariantID, result.Confidence)
// Send approval email if required
if config.Experiments.ApprovalRequired {
sendExperimentApprovalEmail(repo, exp, result)
}
}
}
return nil
}
// sendExperimentApprovalEmail logs the experiment completion.
// Actual email sending is handled by services/mailer/mail_pages.go
// which is called from the cron task handler to avoid import cycles.
func sendExperimentApprovalEmail(repo *repo_model.Repository, exp *pages_model.PageExperiment, result *AnalysisResult) {
log.Info("Experiment %d completed: %s. Winner variant: %d. Approval email should be sent to repo owner %s.",
exp.ID, result.Summary, result.WinnerVariantID, repo.OwnerName)
}

View File

@@ -0,0 +1,193 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package pages
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
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/json"
"code.gitcaddy.com/server/v3/modules/log"
pages_module "code.gitcaddy.com/server/v3/modules/pages"
"code.gitcaddy.com/server/v3/modules/setting"
)
// experimentTokenSecret is derived from the app's secret key
func experimentTokenSecret() []byte {
h := sha256.Sum256([]byte(setting.SecretKey + ":page-experiment"))
return h[:]
}
// CreateExperimentToken creates an HMAC-signed token for experiment approval.
func CreateExperimentToken(experimentID int64, action string) string {
data := fmt.Sprintf("%d:%s:%d", experimentID, action, time.Now().Unix())
mac := hmac.New(sha256.New, experimentTokenSecret())
mac.Write([]byte(data))
sig := hex.EncodeToString(mac.Sum(nil))
return hex.EncodeToString([]byte(data)) + "." + sig
}
// VerifyExperimentToken verifies an experiment approval token and returns the experiment ID.
func VerifyExperimentToken(_ context.Context, tokenStr string) (string, error) {
parts := strings.SplitN(tokenStr, ".", 2)
if len(parts) != 2 {
return "", errors.New("invalid token format")
}
dataBytes, err := hex.DecodeString(parts[0])
if err != nil {
return "", errors.New("invalid token encoding")
}
data := string(dataBytes)
// Verify HMAC
mac := hmac.New(sha256.New, experimentTokenSecret())
mac.Write(dataBytes)
expectedSig := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(parts[1]), []byte(expectedSig)) {
return "", errors.New("invalid token signature")
}
// Parse data: "experimentID:action:timestamp"
dataParts := strings.SplitN(data, ":", 3)
if len(dataParts) != 3 {
return "", errors.New("invalid token data")
}
// Check expiry (7 days)
ts, err := strconv.ParseInt(dataParts[2], 10, 64)
if err != nil {
return "", errors.New("invalid token timestamp")
}
if time.Since(time.Unix(ts, 0)) > 7*24*time.Hour {
return "", errors.New("token expired")
}
return dataParts[0], nil
}
// GenerateExperiment uses the AI sidecar to create an A/B test experiment for a landing page.
func GenerateExperiment(ctx context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) (*pages_model.PageExperiment, error) {
if !ai.IsEnabled() {
return nil, errors.New("AI service is not enabled")
}
client := ai.GetClient()
configJSON, err := json.Marshal(config)
if err != nil {
return nil, fmt.Errorf("failed to marshal config: %w", err)
}
resp, err := client.ExecuteTask(ctx, &ai.ExecuteTaskRequest{
RepoID: repo.ID,
Task: "ab_test_generate",
Context: map[string]string{
"landing_config": string(configJSON),
"repo_name": repo.Name,
"repo_description": repo.Description,
"instruction": `Analyze this landing page config and create an A/B test experiment.
Return valid JSON with this exact structure:
{
"name": "Short experiment name",
"variants": [
{
"name": "Variant A",
"config_override": { ... partial landing config fields to override ... },
"weight": 50
}
]
}
Focus on high-impact changes: headlines, CTAs, value propositions.
Keep variants meaningfully different but plausible.
The control variant (original config) is added automatically — do NOT include it.
Return 1-3 variants. Each variant's config_override should be a partial
LandingConfig with only the fields that differ from the control.`,
},
})
if err != nil {
return nil, fmt.Errorf("AI task failed: %w", err)
}
if !resp.Success {
return nil, fmt.Errorf("AI task error: %s", resp.Error)
}
// Parse AI response
var result struct {
Name string `json:"name"`
Variants []struct {
Name string `json:"name"`
ConfigOverride map[string]any `json:"config_override"`
Weight int `json:"weight"`
} `json:"variants"`
}
if err := json.Unmarshal([]byte(resp.Result), &result); err != nil {
return nil, fmt.Errorf("failed to parse AI response: %w", err)
}
if result.Name == "" || len(result.Variants) == 0 {
return nil, errors.New("AI returned empty experiment")
}
// Create experiment
exp := &pages_model.PageExperiment{
RepoID: repo.ID,
Name: result.Name,
Status: pages_model.ExperimentStatusDraft,
CreatedByAI: true,
}
if err := pages_model.CreateExperiment(ctx, exp); err != nil {
return nil, fmt.Errorf("failed to create experiment: %w", err)
}
// Create control variant
controlVariant := &pages_model.PageVariant{
ExperimentID: exp.ID,
Name: "Control",
IsControl: true,
Weight: 50,
}
if err := pages_model.CreateVariant(ctx, controlVariant); err != nil {
return nil, fmt.Errorf("failed to create control variant: %w", err)
}
// Create AI-generated variants
remainingWeight := 50
for i, v := range result.Variants {
overrideJSON, err := json.Marshal(v.ConfigOverride)
if err != nil {
log.Error("Failed to marshal variant %d config: %v", i, err)
continue
}
weight := v.Weight
if weight <= 0 {
weight = remainingWeight / (len(result.Variants) - i)
}
remainingWeight -= weight
variant := &pages_model.PageVariant{
ExperimentID: exp.ID,
Name: v.Name,
IsControl: false,
Weight: weight,
ConfigOverride: string(overrideJSON),
}
if err := pages_model.CreateVariant(ctx, variant); err != nil {
log.Error("Failed to create variant %s: %v", v.Name, err)
}
}
return exp, nil
}

View File

@@ -11,6 +11,7 @@ import (
"code.gitcaddy.com/server/v3/models/db"
git_model "code.gitcaddy.com/server/v3/models/git"
pages_model "code.gitcaddy.com/server/v3/models/pages"
repo_model "code.gitcaddy.com/server/v3/models/repo"
"code.gitcaddy.com/server/v3/models/unit"
user_model "code.gitcaddy.com/server/v3/models/user"
@@ -119,6 +120,9 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork
return nil, err
}
// Record fork event for A/B testing analytics
pages_model.RecordRepoAction(ctx, opts.BaseRepo.ID, pages_model.EventTypeFork)
// last - clean up if something goes wrong
// WARNING: Don't override all later err with local variables
defer func() {

View File

@@ -1,2 +1,36 @@
{{if .ABTestActive}}
<script>
(function() {
var vid = '{{.VisitorID}}';
var eid = {{.ExperimentID}};
var varid = {{.VariantID}};
var base = '{{.EventTrackURL}}';
function send(type, data) {
try {
navigator.sendBeacon(base, JSON.stringify({
event_type: type, visitor_id: vid,
experiment_id: eid, variant_id: varid, data: data || ''
}));
} catch(e) {}
}
send('impression');
document.querySelectorAll('[data-cta]').forEach(function(el) {
el.addEventListener('click', function() { send('cta_click', el.dataset.cta); });
});
var maxScroll = 0;
window.addEventListener('scroll', function() {
var pct = Math.round((window.scrollY + window.innerHeight) / document.body.scrollHeight * 100);
if (pct > maxScroll) { maxScroll = pct; }
});
window.addEventListener('beforeunload', function() {
if (maxScroll > 0) { send('scroll_depth', JSON.stringify({max_percent: maxScroll})); }
});
})();
</script>
{{end}}
</body>
</html>

View File

@@ -5,7 +5,11 @@
<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 .Config.Brand.FaviconURL}}
<link rel="icon" href="{{.Config.Brand.FaviconURL}}">
{{else}}
<link rel="icon" href="{{AssetUrlPrefix}}/img/favicon.svg" type="image/svg+xml">
{{end}}
{{template "base/head_style" .}}
<style>
/* Pages standalone styles - no GitCaddy navbar */

View File

@@ -1125,19 +1125,23 @@
<nav class="nb-nav">
<a href="/" class="nb-nav-brand">{{if .Config.Brand.Name}}{{.Config.Brand.Name}}{{else}}{{.Repository.Name}}{{end}}</a>
<div class="nb-nav-links">
{{if .Config.Footer.Links}}
{{range .Config.Footer.Links}}
<a href="{{.URL}}" class="nb-nav-link">{{.Label}}</a>
{{end}}
{{end}}
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="nb-nav-link">Docs</a>{{end}}
{{if .Config.Navigation.ShowAPI}}<a href="{{.RepoURL}}/swagger" class="nb-nav-link">API</a>{{end}}
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="nb-nav-link">Releases</a>{{end}}
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="nb-nav-link">Issues</a>{{end}}
{{if .Config.ValueProps}}<a href="#value-props" class="nb-nav-link">Why</a>{{end}}
{{if .Config.Features}}<a href="#features" class="nb-nav-link">Features</a>{{end}}
{{if .Config.Pricing.Plans}}<a href="#pricing" class="nb-nav-link">Pricing</a>{{end}}
{{if .Config.Blog.Enabled}}<a href="{{if .BlogBaseURL}}{{.BlogBaseURL}}{{else}}#blog{{end}}" class="nb-nav-link">Blog</a>{{end}}
{{if .Config.Navigation.ShowRepository}}
<a href="{{.RepoURL}}" class="nb-nav-repo">
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
Repo
</a>
{{end}}
{{if .Config.Hero.PrimaryCTA.Label}}
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="nb-btn-primary small">
{{.Config.Hero.PrimaryCTA.Label}}
@@ -1147,19 +1151,23 @@
<button class="nb-mobile-toggle" onclick="document.getElementById('nb-mobile-nav').classList.toggle('open')">Menu</button>
</nav>
<div class="nb-mobile-menu" id="nb-mobile-nav">
{{if .Config.Footer.Links}}
{{range .Config.Footer.Links}}
<a href="{{.URL}}" class="nb-nav-link">{{.Label}}</a>
{{end}}
{{end}}
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="nb-nav-link">Docs</a>{{end}}
{{if .Config.Navigation.ShowAPI}}<a href="{{.RepoURL}}/swagger" class="nb-nav-link">API</a>{{end}}
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="nb-nav-link">Releases</a>{{end}}
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="nb-nav-link">Issues</a>{{end}}
{{if .Config.ValueProps}}<a href="#value-props" class="nb-nav-link">Why</a>{{end}}
{{if .Config.Features}}<a href="#features" class="nb-nav-link">Features</a>{{end}}
{{if .Config.Pricing.Plans}}<a href="#pricing" class="nb-nav-link">Pricing</a>{{end}}
{{if .Config.Blog.Enabled}}<a href="{{if .BlogBaseURL}}{{.BlogBaseURL}}{{else}}#blog{{end}}" class="nb-nav-link">Blog</a>{{end}}
{{if .Config.Navigation.ShowRepository}}
<a href="{{.RepoURL}}" class="nb-nav-repo">
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
Repository
</a>
{{end}}
</div>
{{if .PageIsBlogDetail}}
@@ -1182,7 +1190,7 @@
{{.BlogRenderedContent | SafeHTML}}
</div>
<div style="margin-top: 48px; padding-top: 24px; border-top: 2px solid var(--nb-border-hard);">
<a href="{{.BlogBaseURL}}" class="nb-btn-secondary" style="text-decoration: none;">
<a href="{{.BlogBaseURL}}" class="nb-btn-secondary" data-cta="secondary" style="text-decoration: none;">
{{svg "octicon-arrow-left" 16}} Back to Blog
</a>
</div>
@@ -1237,13 +1245,13 @@
<p class="nb-hero-sub nb-reveal nb-reveal-delay-2">{{if .Config.Hero.Subheadline}}{{.Config.Hero.Subheadline}}{{else}}{{.Repository.Description}}{{end}}</p>
<div class="nb-hero-buttons nb-reveal nb-reveal-delay-3">
{{if .Config.Hero.PrimaryCTA.Label}}
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="nb-btn-primary">
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="nb-btn-primary" data-cta="primary">
{{.Config.Hero.PrimaryCTA.Label}}
{{svg "octicon-arrow-right" 18}}
</a>
{{end}}
{{if .Config.Hero.SecondaryCTA.Label}}
<a href="{{.Config.Hero.SecondaryCTA.URL}}" class="nb-btn-secondary">
<a href="{{.Config.Hero.SecondaryCTA.URL}}" class="nb-btn-secondary" data-cta="secondary">
{{svg "octicon-play" 18}}
{{.Config.Hero.SecondaryCTA.Label}}
</a>
@@ -1414,7 +1422,7 @@
<p>{{.Config.CTASection.Subheadline}}</p>
{{end}}
{{if .Config.CTASection.Button.Label}}
<a href="{{.Config.CTASection.Button.URL}}" class="nb-btn-cta">
<a href="{{.Config.CTASection.Button.URL}}" class="nb-btn-cta" data-cta="cta-section">
{{.Config.CTASection.Button.Label}}
{{svg "octicon-arrow-right" 20}}
</a>
@@ -1452,7 +1460,7 @@
</div>
{{if .Config.Blog.CTAButton.Label}}
<div style="text-align: center; margin-top: 48px;" class="nb-reveal">
<a href="{{if .Config.Blog.CTAButton.URL}}{{.Config.Blog.CTAButton.URL}}{{else}}{{.BlogBaseURL}}{{end}}" class="nb-btn-secondary">
<a href="{{if .Config.Blog.CTAButton.URL}}{{.Config.Blog.CTAButton.URL}}{{else}}{{.BlogBaseURL}}{{end}}" class="nb-btn-secondary" data-cta="secondary">
{{.Config.Blog.CTAButton.Label}}
{{svg "octicon-arrow-right" 18}}
</a>
@@ -1488,14 +1496,13 @@
</div>
{{end}}
<div class="nb-footer-links">
{{if .Config.Footer.Links}}
{{range .Config.Footer.Links}}
<a href="{{.URL}}" class="nb-footer-link">{{.Label}}</a>
{{end}}
{{else}}
<a href="{{.RepoURL}}" class="nb-footer-link">Repository</a>
<a href="{{.RepoURL}}/releases" class="nb-footer-link">Releases</a>
{{end}}
{{if .Config.Navigation.ShowRepository}}<a href="{{.RepoURL}}" class="nb-footer-link">Repository</a>{{end}}
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="nb-footer-link">Docs</a>{{end}}
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="nb-footer-link">Releases</a>{{end}}
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="nb-footer-link">Issues</a>{{end}}
</div>
</footer>
</div>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #f8f9fa; color: #1a1a2e; }
.card { background: #fff; border-radius: 12px; padding: 48px; max-width: 480px; text-align: center; box-shadow: 0 4px 24px rgba(0,0,0,0.08); }
.icon { font-size: 48px; margin-bottom: 16px; }
h1 { font-size: 24px; margin: 0 0 12px; }
p { color: #666; line-height: 1.6; margin: 0; }
.name { font-weight: 600; color: #1a1a2e; }
</style>
</head>
<body>
<div class="card">
{{if .ExperimentApproved}}
<div class="icon">&#10004;</div>
<h1>Experiment Approved</h1>
<p>The winning variant from <span class="name">{{.ExperimentName}}</span> will be applied to your landing page.</p>
{{else if .ExperimentDeclined}}
<div class="icon">&#10006;</div>
<h1>Experiment Declined</h1>
<p>The experiment <span class="name">{{.ExperimentName}}</span> has been paused. Your landing page remains unchanged.</p>
{{end}}
</div>
</body>
</html>

View File

@@ -1,21 +1,22 @@
<footer class="pages-footer">
<div class="container">
{{if .Config.Footer.Links}}
<div class="pages-footer-links">
{{range .Config.Footer.Links}}
<div class="pages-footer-column">
{{if .Title}}
<h4 class="pages-footer-title">{{.Title}}</h4>
{{end}}
<ul class="pages-footer-list">
{{range .Items}}
<li><a href="{{.URL}}">{{.Text}}</a></li>
{{end}}
<li><a href="{{.URL}}">{{.Label}}</a></li>
</ul>
</div>
{{end}}
<div class="pages-footer-column">
<ul class="pages-footer-list">
{{if .Config.Navigation.ShowRepository}}<li><a href="{{AppSubUrl}}/{{.Repository.FullName}}">Repository</a></li>{{end}}
{{if .Config.Navigation.ShowDocs}}<li><a href="{{AppSubUrl}}/{{.Repository.FullName}}/wiki">Documentation</a></li>{{end}}
{{if .Config.Navigation.ShowReleases}}<li><a href="{{AppSubUrl}}/{{.Repository.FullName}}/releases">Releases</a></li>{{end}}
{{if .Config.Navigation.ShowIssues}}<li><a href="{{AppSubUrl}}/{{.Repository.FullName}}/issues">Issues</a></li>{{end}}
</ul>
</div>
</div>
{{end}}
<div class="pages-footer-bottom">
{{if .Config.Footer.Copyright}}

View File

@@ -9,16 +9,18 @@
{{end}}
</a>
<div class="pages-nav-links">
{{if .Config.Footer.Links}}
{{range .Config.Footer.Links}}
{{range .Items}}
<a href="{{.URL}}" class="pages-nav-link">{{.Text}}</a>
{{end}}
{{end}}
<a href="{{.URL}}" class="pages-nav-link">{{.Label}}</a>
{{end}}
{{if .Config.Navigation.ShowDocs}}<a href="{{AppSubUrl}}/{{.Repository.FullName}}/wiki" class="pages-nav-link">Docs</a>{{end}}
{{if .Config.Navigation.ShowAPI}}<a href="{{AppSubUrl}}/{{.Repository.FullName}}/swagger" class="pages-nav-link">API</a>{{end}}
{{if .Config.Navigation.ShowReleases}}<a href="{{AppSubUrl}}/{{.Repository.FullName}}/releases" class="pages-nav-link">Releases</a>{{end}}
{{if .Config.Navigation.ShowIssues}}<a href="{{AppSubUrl}}/{{.Repository.FullName}}/issues" class="pages-nav-link">Issues</a>{{end}}
{{if .Config.Navigation.ShowRepository}}
<a href="{{AppSubUrl}}/{{.Repository.FullName}}" class="ui mini button" target="_blank">
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy"> View Source
</a>
{{end}}
</div>
</nav>
</div>

View File

@@ -992,42 +992,44 @@
<nav class="ea-nav">
<a href="/" class="ea-nav-brand">{{if .Config.Brand.Name}}{{.Config.Brand.Name}}{{else}}{{.Repository.Name}}{{end}}</a>
<div class="ea-nav-links">
{{if .Config.Footer.Links}}
{{range .Config.Footer.Links}}
<a href="{{.URL}}" class="ea-nav-link">{{.Label}}</a>
{{end}}
{{else}}
<a href="{{.RepoURL}}/wiki" class="ea-nav-link">Docs</a>
<a href="{{.RepoURL}}" class="ea-nav-link">API</a>
{{end}}
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="ea-nav-link">Docs</a>{{end}}
{{if .Config.Navigation.ShowAPI}}<a href="{{.RepoURL}}/swagger" class="ea-nav-link">API</a>{{end}}
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="ea-nav-link">Releases</a>{{end}}
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="ea-nav-link">Issues</a>{{end}}
{{if .Config.ValueProps}}<a href="#why" class="ea-nav-link">Why</a>{{end}}
{{if .Config.Features}}<a href="#features" class="ea-nav-link">Features</a>{{end}}
{{if .Config.Pricing.Plans}}<a href="#pricing" class="ea-nav-link">Pricing</a>{{end}}
{{if .Config.Blog.Enabled}}<a href="{{if .BlogBaseURL}}{{.BlogBaseURL}}{{else}}#blog{{end}}" class="ea-nav-link">Blog</a>{{end}}
{{if .Config.Navigation.ShowRepository}}
<a href="{{.RepoURL}}" class="ea-btn-text">
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
Repository
</a>
{{end}}
</div>
<button class="ea-mobile-toggle" onclick="document.getElementById('ea-mobile-nav').classList.toggle('open')">Menu</button>
</nav>
<div class="ea-mobile-menu" id="ea-mobile-nav">
{{if .Config.Footer.Links}}
{{range .Config.Footer.Links}}
<a href="{{.URL}}" class="ea-nav-link">{{.Label}}</a>
{{end}}
{{else}}
<a href="{{.RepoURL}}/wiki" class="ea-nav-link">Docs</a>
<a href="{{.RepoURL}}" class="ea-nav-link">API</a>
{{end}}
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="ea-nav-link">Docs</a>{{end}}
{{if .Config.Navigation.ShowAPI}}<a href="{{.RepoURL}}/swagger" class="ea-nav-link">API</a>{{end}}
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="ea-nav-link">Releases</a>{{end}}
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="ea-nav-link">Issues</a>{{end}}
{{if .Config.ValueProps}}<a href="#why" class="ea-nav-link">Why</a>{{end}}
{{if .Config.Features}}<a href="#features" class="ea-nav-link">Features</a>{{end}}
{{if .Config.Pricing.Plans}}<a href="#pricing" class="ea-nav-link">Pricing</a>{{end}}
{{if .Config.Blog.Enabled}}<a href="{{if .BlogBaseURL}}{{.BlogBaseURL}}{{else}}#blog{{end}}" class="ea-nav-link">Blog</a>{{end}}
{{if .Config.Navigation.ShowRepository}}
<a href="{{.RepoURL}}" class="ea-btn-text">
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
Repository
</a>
{{end}}
</div>
{{if .PageIsBlogDetail}}
@@ -1103,12 +1105,12 @@
<div class="ea-hero-ctas ea-reveal ea-reveal-delay-3">
{{if .Config.Hero.PrimaryCTA.Label}}
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="ea-btn-primary">
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="ea-btn-primary" data-cta="primary">
{{.Config.Hero.PrimaryCTA.Label}}
{{svg "octicon-arrow-right" 14}}
</a>
{{else}}
<a href="{{.RepoURL}}" class="ea-btn-primary">
<a href="{{.RepoURL}}" class="ea-btn-primary" data-cta="primary">
Get Started
{{svg "octicon-arrow-right" 14}}
</a>
@@ -1297,7 +1299,7 @@
{{end}}
{{if .Config.CTASection.Button.Label}}
<div class="ea-reveal ea-reveal-delay-2">
<a href="{{.Config.CTASection.Button.URL}}" class="ea-btn-primary">
<a href="{{.Config.CTASection.Button.URL}}" class="ea-btn-primary" data-cta="primary">
{{.Config.CTASection.Button.Label}}
{{svg "octicon-arrow-right" 14}}
</a>
@@ -1361,14 +1363,13 @@
</div>
{{end}}
<div class="ea-footer-links">
{{if .Config.Footer.Links}}
{{range .Config.Footer.Links}}
<a href="{{.URL}}" class="ea-footer-link">{{.Label}}</a>
{{end}}
{{else}}
<a href="{{.RepoURL}}" class="ea-footer-link">Repository</a>
<a href="{{.RepoURL}}/wiki" class="ea-footer-link">Documentation</a>
{{end}}
{{if .Config.Navigation.ShowRepository}}<a href="{{.RepoURL}}" class="ea-footer-link">Repository</a>{{end}}
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="ea-footer-link">Documentation</a>{{end}}
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="ea-footer-link">Releases</a>{{end}}
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="ea-footer-link">Issues</a>{{end}}
</div>
</footer>
</div>

View File

@@ -983,23 +983,23 @@
<span class="osh-nav-name">{{if .Config.Brand.Name}}{{.Config.Brand.Name}}{{else}}{{.Repository.Name}}{{end}}</span>
</a>
<div class="osh-nav-links">
{{if .Config.Footer.Links}}
{{range .Config.Footer.Links}}
<a href="{{.URL}}" class="osh-nav-link">{{.Label}}</a>
{{end}}
{{else}}
<a href="{{.RepoURL}}" class="osh-nav-link">Repository</a>
<a href="{{.RepoURL}}/wiki" class="osh-nav-link">Docs</a>
<a href="{{.RepoURL}}/releases" class="osh-nav-link">Releases</a>
{{end}}
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="osh-nav-link">Docs</a>{{end}}
{{if .Config.Navigation.ShowAPI}}<a href="{{.RepoURL}}/swagger" class="osh-nav-link">API</a>{{end}}
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="osh-nav-link">Releases</a>{{end}}
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="osh-nav-link">Issues</a>{{end}}
{{if .Config.ValueProps}}<a href="#value-props" class="osh-nav-link">Why Us</a>{{end}}
{{if .Config.Features}}<a href="#features" class="osh-nav-link">Features</a>{{end}}
{{if .Config.Pricing.Plans}}<a href="#pricing" class="osh-nav-link">Pricing</a>{{end}}
{{if .Config.Blog.Enabled}}<a href="{{if .BlogBaseURL}}{{.BlogBaseURL}}{{else}}#blog{{end}}" class="osh-nav-link">Blog</a>{{end}}
{{if .Config.Navigation.ShowRepository}}
<a href="{{.RepoURL}}" class="osh-nav-cta">
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
Repository
</a>
{{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}}
@@ -1026,7 +1026,7 @@
{{.BlogRenderedContent | SafeHTML}}
</div>
<div style="margin-top: 48px; padding-top: 24px; border-top: 1px solid rgba(255,255,255,0.06);">
<a href="{{.BlogBaseURL}}" class="osh-btn-secondary" style="text-decoration: none;">
<a href="{{.BlogBaseURL}}" class="osh-btn-secondary" data-cta="secondary" style="text-decoration: none;">
{{svg "octicon-arrow-left" 16}} Back to Blog
</a>
</div>
@@ -1085,24 +1085,24 @@
<div class="osh-hero-ctas osh-reveal visible osh-reveal-delay-3">
{{if .Config.Hero.PrimaryCTA.Label}}
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="osh-btn-primary">
<a href="{{.Config.Hero.PrimaryCTA.URL}}" class="osh-btn-primary" data-cta="primary">
{{.Config.Hero.PrimaryCTA.Label}}
{{svg "octicon-arrow-right" 16}}
</a>
{{else}}
<a href="{{.RepoURL}}" class="osh-btn-primary">
<a href="{{.RepoURL}}" class="osh-btn-primary" data-cta="primary">
Get Started
{{svg "octicon-arrow-right" 16}}
</a>
{{end}}
{{if .Config.Hero.SecondaryCTA.Label}}
<a href="{{.Config.Hero.SecondaryCTA.URL}}" class="osh-btn-secondary">
<a href="{{.Config.Hero.SecondaryCTA.URL}}" class="osh-btn-secondary" data-cta="secondary">
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
{{.Config.Hero.SecondaryCTA.Label}}
</a>
{{else}}
<a href="{{.RepoURL}}" class="osh-btn-secondary">
<a href="{{.RepoURL}}" class="osh-btn-secondary" data-cta="secondary">
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
View Source
</a>
@@ -1293,7 +1293,7 @@
{{if .Config.CTASection.Subheadline}}
<p>{{.Config.CTASection.Subheadline}}</p>
{{end}}
<a href="{{if .Config.CTASection.Button.URL}}{{.Config.CTASection.Button.URL}}{{else}}{{.RepoURL}}{{end}}" class="osh-btn-primary" style="padding: 16px 32px; font-size: 15px;">
<a href="{{if .Config.CTASection.Button.URL}}{{.Config.CTASection.Button.URL}}{{else}}{{.RepoURL}}{{end}}" class="osh-btn-primary" data-cta="primary" style="padding: 16px 32px; font-size: 15px;">
{{if .Config.CTASection.Button.Label}}{{.Config.CTASection.Button.Label}}{{else}}Get Started{{end}}
{{svg "octicon-arrow-right" 16}}
</a>
@@ -1330,7 +1330,7 @@
</div>
{{if .Config.Blog.CTAButton.Label}}
<div style="text-align: center; margin-top: 48px;" class="osh-reveal">
<a href="{{if .Config.Blog.CTAButton.URL}}{{.Config.Blog.CTAButton.URL}}{{else}}{{.BlogBaseURL}}{{end}}" class="osh-btn-secondary">
<a href="{{if .Config.Blog.CTAButton.URL}}{{.Config.Blog.CTAButton.URL}}{{else}}{{.BlogBaseURL}}{{end}}" class="osh-btn-secondary" data-cta="secondary">
{{.Config.Blog.CTAButton.Label}}
{{svg "octicon-arrow-right" 16}}
</a>
@@ -1363,16 +1363,13 @@
</div>
{{end}}
<div class="osh-footer-links">
{{if .Config.Footer.Links}}
{{range .Config.Footer.Links}}
<a href="{{.URL}}" class="osh-footer-link">{{.Label}}</a>
{{end}}
{{else}}
<a href="{{.RepoURL}}" class="osh-footer-link">Repository</a>
<a href="{{.RepoURL}}/wiki" class="osh-footer-link">Documentation</a>
<a href="{{.RepoURL}}/releases" class="osh-footer-link">Releases</a>
<a href="{{.RepoURL}}/issues" class="osh-footer-link">Issues</a>
{{end}}
{{if .Config.Navigation.ShowRepository}}<a href="{{.RepoURL}}" class="osh-footer-link">Repository</a>{{end}}
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="osh-footer-link">Documentation</a>{{end}}
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="osh-footer-link">Releases</a>{{end}}
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="osh-footer-link">Issues</a>{{end}}
</div>
</footer>
</div>

View File

@@ -1100,39 +1100,47 @@
<span class="gm-nav-name">{{if .Config.Brand.Name}}{{.Config.Brand.Name}}{{else}}{{.Repository.Name}}{{end}}</span>
</a>
<div class="gm-nav-links">
{{if .Config.Footer.Links}}
{{range .Config.Footer.Links}}
<a href="{{.URL}}" class="gm-nav-link">{{.Label}}</a>
{{end}}
{{end}}
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="gm-nav-link">Docs</a>{{end}}
{{if .Config.Navigation.ShowAPI}}<a href="{{.RepoURL}}/swagger" class="gm-nav-link">API</a>{{end}}
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="gm-nav-link">Releases</a>{{end}}
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="gm-nav-link">Issues</a>{{end}}
{{if .Config.ValueProps}}<a href="#value-props" class="gm-nav-link">Why</a>{{end}}
{{if .Config.Features}}<a href="#features" class="gm-nav-link">Features</a>{{end}}
{{if .Config.Pricing.Plans}}<a href="#pricing" class="gm-nav-link">Pricing</a>{{end}}
{{if .Config.Blog.Enabled}}<a href="{{if .BlogBaseURL}}{{.BlogBaseURL}}{{else}}#blog{{end}}" class="gm-nav-link">Blog</a>{{end}}
{{if .Config.Navigation.ShowRepository}}
<a href="{{.RepoURL}}" class="gm-nav-repo">
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
Repository
</a>
<a href="{{if .Config.Hero.PrimaryCTA.URL}}{{.Config.Hero.PrimaryCTA.URL}}{{else}}{{.RepoURL}}{{end}}" class="gm-btn-primary" style="padding: 10px 20px; font-size: 13px;">
{{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>
</div>
<button class="gm-mobile-toggle" onclick="document.getElementById('gm-mobile-nav').classList.toggle('open')">Menu</button>
</nav>
<div class="gm-mobile-menu" id="gm-mobile-nav">
{{if .Config.Footer.Links}}
{{range .Config.Footer.Links}}
<a href="{{.URL}}" class="gm-nav-link">{{.Label}}</a>
{{end}}
{{end}}
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="gm-nav-link">Docs</a>{{end}}
{{if .Config.Navigation.ShowAPI}}<a href="{{.RepoURL}}/swagger" class="gm-nav-link">API</a>{{end}}
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="gm-nav-link">Releases</a>{{end}}
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="gm-nav-link">Issues</a>{{end}}
{{if .Config.ValueProps}}<a href="#value-props" class="gm-nav-link">Why</a>{{end}}
{{if .Config.Features}}<a href="#features" class="gm-nav-link">Features</a>{{end}}
{{if .Config.Pricing.Plans}}<a href="#pricing" class="gm-nav-link">Pricing</a>{{end}}
{{if .Config.Blog.Enabled}}<a href="{{if .BlogBaseURL}}{{.BlogBaseURL}}{{else}}#blog{{end}}" class="gm-nav-link">Blog</a>{{end}}
{{if .Config.Navigation.ShowRepository}}
<a href="{{.RepoURL}}" class="gm-nav-repo">
<img src="/assets/img/gitcaddy-icon.svg" width="16" height="16" alt="GitCaddy">
Repository
</a>
{{end}}
</div>
{{if .PageIsBlogDetail}}
@@ -1155,7 +1163,7 @@
{{.BlogRenderedContent | SafeHTML}}
</div>
<div style="margin-top: 48px; padding-top: 24px; border-top: 1px solid var(--gm-glass-border);">
<a href="{{.BlogBaseURL}}" class="gm-btn-secondary" style="text-decoration: none;">
<a href="{{.BlogBaseURL}}" class="gm-btn-secondary" data-cta="secondary" style="text-decoration: none;">
{{svg "octicon-arrow-left" 16}} Back to Blog
</a>
</div>
@@ -1214,14 +1222,14 @@
</p>
<div class="gm-hero-ctas gm-reveal gm-reveal-delay-3">
<a href="{{if .Config.Hero.PrimaryCTA.URL}}{{.Config.Hero.PrimaryCTA.URL}}{{else}}{{.RepoURL}}{{end}}" class="gm-btn-primary">
<a href="{{if .Config.Hero.PrimaryCTA.URL}}{{.Config.Hero.PrimaryCTA.URL}}{{else}}{{.RepoURL}}{{end}}" class="gm-btn-primary" data-cta="primary">
<span>
{{if .Config.Hero.PrimaryCTA.Label}}{{.Config.Hero.PrimaryCTA.Label}}{{else}}Get Started{{end}}
{{svg "octicon-arrow-right" 16}}
</span>
</a>
{{if .Config.Hero.SecondaryCTA.Label}}
<a href="{{.Config.Hero.SecondaryCTA.URL}}" class="gm-btn-secondary">
<a href="{{.Config.Hero.SecondaryCTA.URL}}" class="gm-btn-secondary" data-cta="secondary">
{{svg "octicon-play" 16}}
{{.Config.Hero.SecondaryCTA.Label}}
</a>
@@ -1423,7 +1431,7 @@
{{if .Config.CTASection.Subheadline}}
<p>{{.Config.CTASection.Subheadline}}</p>
{{end}}
<a href="{{if .Config.CTASection.Button.URL}}{{.Config.CTASection.Button.URL}}{{else}}{{.RepoURL}}{{end}}" class="gm-btn-primary" style="font-size: 16px; padding: 16px 36px;">
<a href="{{if .Config.CTASection.Button.URL}}{{.Config.CTASection.Button.URL}}{{else}}{{.RepoURL}}{{end}}" class="gm-btn-primary" data-cta="primary" style="font-size: 16px; padding: 16px 36px;">
<span>
{{if .Config.CTASection.Button.Label}}{{.Config.CTASection.Button.Label}}{{else}}Get Started Free{{end}}
{{svg "octicon-arrow-right" 16}}
@@ -1461,7 +1469,7 @@
</div>
{{if .Config.Blog.CTAButton.Label}}
<div style="text-align: center; margin-top: 48px;" class="gm-reveal">
<a href="{{if .Config.Blog.CTAButton.URL}}{{.Config.Blog.CTAButton.URL}}{{else}}{{.BlogBaseURL}}{{end}}" class="gm-btn-secondary">
<a href="{{if .Config.Blog.CTAButton.URL}}{{.Config.Blog.CTAButton.URL}}{{else}}{{.BlogBaseURL}}{{end}}" class="gm-btn-secondary" data-cta="secondary">
{{.Config.Blog.CTAButton.Label}}
{{svg "octicon-arrow-right" 16}}
</a>
@@ -1494,16 +1502,13 @@
</div>
{{end}}
<div class="gm-footer-links">
{{if .Config.Footer.Links}}
{{range .Config.Footer.Links}}
<a href="{{.URL}}" class="gm-footer-link">{{.Label}}</a>
{{end}}
{{else}}
<a href="{{.RepoURL}}" class="gm-footer-link">Repository</a>
<a href="{{.RepoURL}}/wiki" class="gm-footer-link">Docs</a>
<a href="{{.RepoURL}}/releases" class="gm-footer-link">Releases</a>
<a href="{{.RepoURL}}/issues" class="gm-footer-link">Issues</a>
{{end}}
{{if .Config.Navigation.ShowRepository}}<a href="{{.RepoURL}}" class="gm-footer-link">Repository</a>{{end}}
{{if .Config.Navigation.ShowDocs}}<a href="{{.RepoURL}}/wiki" class="gm-footer-link">Docs</a>{{end}}
{{if .Config.Navigation.ShowReleases}}<a href="{{.RepoURL}}/releases" class="gm-footer-link">Releases</a>{{end}}
{{if .Config.Navigation.ShowIssues}}<a href="{{.RepoURL}}/issues" class="gm-footer-link">Issues</a>{{end}}
</div>
</footer>
</div>

View File

@@ -20,6 +20,11 @@
<label>{{ctx.Locale.Tr "repo.settings.pages.brand_tagline"}}</label>
<input name="brand_tagline" value="{{.Config.Brand.Tagline}}" placeholder="Your tagline here">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.brand_favicon_url"}}</label>
<input name="brand_favicon_url" value="{{.Config.Brand.FaviconURL}}" placeholder="https://example.com/favicon.ico">
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.brand_favicon_url_help"}}</p>
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button>
</div>

View File

@@ -4,6 +4,39 @@
<div class="ui attached segment">
<form class="ui form" method="post">
{{.CsrfTokenHtml}}
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.navigation"}}</h5>
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.navigation_desc"}}</p>
<div class="inline field">
<div class="ui toggle checkbox">
<input type="checkbox" name="nav_show_docs" {{if .Config.Navigation.ShowDocs}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pages.nav_show_docs"}}</label>
</div>
</div>
<div class="inline field">
<div class="ui toggle checkbox">
<input type="checkbox" name="nav_show_api" {{if .Config.Navigation.ShowAPI}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pages.nav_show_api"}}</label>
</div>
</div>
<div class="inline field">
<div class="ui toggle checkbox">
<input type="checkbox" name="nav_show_repository" {{if .Config.Navigation.ShowRepository}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pages.nav_show_repository"}}</label>
</div>
</div>
<div class="inline field">
<div class="ui toggle checkbox">
<input type="checkbox" name="nav_show_releases" {{if .Config.Navigation.ShowReleases}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pages.nav_show_releases"}}</label>
</div>
</div>
<div class="inline field">
<div class="ui toggle checkbox">
<input type="checkbox" name="nav_show_issues" {{if .Config.Navigation.ShowIssues}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pages.nav_show_issues"}}</label>
</div>
</div>
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.public_releases"}}</h5>
<div class="inline field">
<div class="ui toggle checkbox">