Add ability to upload hero images directly instead of only using URLs. Images are stored in repo-avatars storage with hash-based filenames (max 5 MB, JPG/PNG/WebP/GIF). Uploaded images take priority over URL field. Includes upload and delete endpoints, UI with preview, and ResolvedImageURL helper method. Improves UX by eliminating need for external image hosting.
380 lines
12 KiB
Go
380 lines
12 KiB
Go
// Copyright 2026 MarketAlly. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package pages
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"slices"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// LandingConfig represents the parsed .gitea/landing.yaml configuration
|
|
type LandingConfig struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
PublicLanding bool `yaml:"public_landing"`
|
|
Template string `yaml:"template"` // open-source-hero, minimalist-docs, saas-conversion, bold-marketing
|
|
|
|
// Custom domain (optional)
|
|
Domain string `yaml:"domain,omitempty"`
|
|
|
|
// Brand configuration
|
|
Brand BrandConfig `yaml:"brand,omitempty"`
|
|
|
|
// Hero section
|
|
Hero HeroConfig `yaml:"hero,omitempty"`
|
|
|
|
// Stats/metrics
|
|
Stats []StatConfig `yaml:"stats,omitempty"`
|
|
|
|
// Value propositions
|
|
ValueProps []ValuePropConfig `yaml:"value_props,omitempty"`
|
|
|
|
// Features
|
|
Features []FeatureConfig `yaml:"features,omitempty"`
|
|
|
|
// Social proof
|
|
SocialProof SocialProofConfig `yaml:"social_proof,omitempty"`
|
|
|
|
// Pricing (for saas-conversion template)
|
|
Pricing PricingConfig `yaml:"pricing,omitempty"`
|
|
|
|
// CTA section
|
|
CTASection CTASectionConfig `yaml:"cta_section,omitempty"`
|
|
|
|
// Blog section
|
|
Blog BlogSectionConfig `yaml:"blog,omitempty"`
|
|
|
|
// Navigation visibility
|
|
Navigation NavigationConfig `yaml:"navigation,omitempty"`
|
|
|
|
// Footer
|
|
Footer FooterConfig `yaml:"footer,omitempty"`
|
|
|
|
// Theme customization
|
|
Theme ThemeConfig `yaml:"theme,omitempty"`
|
|
|
|
// SEO & Social
|
|
SEO SEOConfig `yaml:"seo,omitempty"`
|
|
|
|
// Analytics
|
|
Analytics AnalyticsConfig `yaml:"analytics,omitempty"`
|
|
|
|
// Advanced settings
|
|
Advanced AdvancedConfig `yaml:"advanced,omitempty"`
|
|
|
|
// A/B testing experiments
|
|
Experiments ExperimentConfig `yaml:"experiments,omitempty"`
|
|
|
|
// Multi-language support
|
|
I18n I18nConfig `yaml:"i18n,omitempty"`
|
|
}
|
|
|
|
// BrandConfig represents brand/identity settings
|
|
type BrandConfig struct {
|
|
Name string `yaml:"name,omitempty"`
|
|
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
|
|
type HeroConfig struct {
|
|
Headline string `yaml:"headline,omitempty"`
|
|
Subheadline string `yaml:"subheadline,omitempty"`
|
|
PrimaryCTA CTAButton `yaml:"primary_cta,omitempty"`
|
|
SecondaryCTA CTAButton `yaml:"secondary_cta,omitempty"`
|
|
ImageURL string `yaml:"image_url,omitempty"`
|
|
UploadedImage string `yaml:"uploaded_image,omitempty"` // filename in repo-avatars storage
|
|
CodeExample string `yaml:"code_example,omitempty"`
|
|
VideoURL string `yaml:"video_url,omitempty"`
|
|
}
|
|
|
|
// ResolvedImageURL returns the effective hero image URL, preferring uploaded image over URL.
|
|
func (h *HeroConfig) ResolvedImageURL() string {
|
|
if h.UploadedImage != "" {
|
|
return "/repo-avatars/" + h.UploadedImage
|
|
}
|
|
return h.ImageURL
|
|
}
|
|
|
|
// CTAButton represents a call-to-action button
|
|
type CTAButton struct {
|
|
Label string `yaml:"label,omitempty"`
|
|
URL string `yaml:"url,omitempty"`
|
|
Variant string `yaml:"variant,omitempty"` // primary, secondary, outline, text
|
|
}
|
|
|
|
// StatConfig represents a single stat/metric
|
|
type StatConfig struct {
|
|
Value string `yaml:"value,omitempty"`
|
|
Label string `yaml:"label,omitempty"`
|
|
}
|
|
|
|
// ValuePropConfig represents a value proposition
|
|
type ValuePropConfig struct {
|
|
Title string `yaml:"title,omitempty"`
|
|
Description string `yaml:"description,omitempty"`
|
|
Icon string `yaml:"icon,omitempty"`
|
|
}
|
|
|
|
// FeatureConfig represents a single feature item
|
|
type FeatureConfig struct {
|
|
Title string `yaml:"title,omitempty"`
|
|
Description string `yaml:"description,omitempty"`
|
|
Icon string `yaml:"icon,omitempty"`
|
|
ImageURL string `yaml:"image_url,omitempty"`
|
|
}
|
|
|
|
// SocialProofConfig represents social proof section
|
|
type SocialProofConfig struct {
|
|
Logos []string `yaml:"logos,omitempty"`
|
|
Testimonial TestimonialConfig `yaml:"testimonial,omitempty"`
|
|
Testimonials []TestimonialConfig `yaml:"testimonials,omitempty"`
|
|
}
|
|
|
|
// TestimonialConfig represents a testimonial
|
|
type TestimonialConfig struct {
|
|
Quote string `yaml:"quote,omitempty"`
|
|
Author string `yaml:"author,omitempty"`
|
|
Role string `yaml:"role,omitempty"`
|
|
Avatar string `yaml:"avatar,omitempty"`
|
|
}
|
|
|
|
// PricingConfig represents pricing section
|
|
type PricingConfig struct {
|
|
Headline string `yaml:"headline,omitempty"`
|
|
Subheadline string `yaml:"subheadline,omitempty"`
|
|
Plans []PricingPlanConfig `yaml:"plans,omitempty"`
|
|
}
|
|
|
|
// PricingPlanConfig represents a pricing plan
|
|
type PricingPlanConfig struct {
|
|
Name string `yaml:"name,omitempty"`
|
|
Price string `yaml:"price,omitempty"`
|
|
Period string `yaml:"period,omitempty"`
|
|
Features []string `yaml:"features,omitempty"`
|
|
CTA string `yaml:"cta,omitempty"`
|
|
Featured bool `yaml:"featured,omitempty"`
|
|
}
|
|
|
|
// CTASectionConfig represents the final CTA section
|
|
type CTASectionConfig struct {
|
|
Headline string `yaml:"headline,omitempty"`
|
|
Subheadline string `yaml:"subheadline,omitempty"`
|
|
Button CTAButton `yaml:"button,omitempty"`
|
|
}
|
|
|
|
// BlogSectionConfig represents blog section settings on the landing page
|
|
type BlogSectionConfig struct {
|
|
Enabled bool `yaml:"enabled,omitempty"`
|
|
Headline string `yaml:"headline,omitempty"`
|
|
Subheadline string `yaml:"subheadline,omitempty"`
|
|
MaxPosts int `yaml:"max_posts,omitempty"` // default 3
|
|
ShowExcerpt bool `yaml:"show_excerpt,omitempty"` // show subtitle as excerpt
|
|
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"`
|
|
Social []SocialLink `yaml:"social,omitempty"`
|
|
Copyright string `yaml:"copyright,omitempty"`
|
|
ShowPoweredBy bool `yaml:"show_powered_by,omitempty"`
|
|
}
|
|
|
|
// FooterLink represents a single footer link
|
|
type FooterLink struct {
|
|
Label string `yaml:"label,omitempty"`
|
|
URL string `yaml:"url,omitempty"`
|
|
}
|
|
|
|
// SocialLink represents a social media link
|
|
type SocialLink struct {
|
|
Platform string `yaml:"platform,omitempty"` // bluesky, discord, facebook, github, instagram, linkedin, mastodon, reddit, rss, substack, threads, tiktok, twitch, twitter, youtube
|
|
URL string `yaml:"url,omitempty"`
|
|
}
|
|
|
|
// ThemeConfig represents theme customization
|
|
type ThemeConfig struct {
|
|
PrimaryColor string `yaml:"primary_color,omitempty"`
|
|
AccentColor string `yaml:"accent_color,omitempty"`
|
|
Mode string `yaml:"mode,omitempty"` // light, dark, auto
|
|
}
|
|
|
|
// SEOConfig represents SEO and social sharing settings
|
|
type SEOConfig struct {
|
|
Title string `yaml:"title,omitempty"`
|
|
Description string `yaml:"description,omitempty"`
|
|
Keywords []string `yaml:"keywords,omitempty"`
|
|
OGImage string `yaml:"og_image,omitempty"`
|
|
UseMediaKitOG bool `yaml:"use_media_kit_og,omitempty"`
|
|
TwitterCard string `yaml:"twitter_card,omitempty"`
|
|
TwitterSite string `yaml:"twitter_site,omitempty"`
|
|
}
|
|
|
|
// AnalyticsConfig represents analytics settings
|
|
type AnalyticsConfig struct {
|
|
Plausible string `yaml:"plausible,omitempty"`
|
|
Umami UmamiConfig `yaml:"umami,omitempty"`
|
|
GoogleAnalytics string `yaml:"google_analytics,omitempty"`
|
|
}
|
|
|
|
// UmamiConfig represents Umami analytics settings
|
|
type UmamiConfig struct {
|
|
WebsiteID string `yaml:"website_id,omitempty"`
|
|
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"`
|
|
}
|
|
|
|
// I18nConfig represents multi-language settings for the landing page
|
|
type I18nConfig struct {
|
|
DefaultLang string `yaml:"default_lang,omitempty" json:"default_lang,omitempty"`
|
|
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
|
|
}
|
|
|
|
// LanguageDisplayNames returns a map of language codes to display names
|
|
func LanguageDisplayNames() map[string]string {
|
|
return map[string]string{
|
|
"en": "English",
|
|
"es": "Espanol",
|
|
"de": "Deutsch",
|
|
"fr": "Francais",
|
|
"ja": "Japanese",
|
|
"zh": "Chinese",
|
|
"pt": "Portugues",
|
|
"ru": "Russian",
|
|
"ko": "Korean",
|
|
"it": "Italiano",
|
|
"hi": "Hindi",
|
|
"ar": "Arabic",
|
|
"nl": "Nederlands",
|
|
"pl": "Polski",
|
|
"tr": "Turkish",
|
|
}
|
|
}
|
|
|
|
// AdvancedConfig represents advanced settings
|
|
type AdvancedConfig struct {
|
|
CustomCSS string `yaml:"custom_css,omitempty"`
|
|
CustomHead string `yaml:"custom_head,omitempty"`
|
|
Redirects map[string]string `yaml:"redirects,omitempty"`
|
|
PublicReleases bool `yaml:"public_releases,omitempty"`
|
|
}
|
|
|
|
// ParseLandingConfig parses a landing.yaml file content
|
|
func ParseLandingConfig(content []byte) (*LandingConfig, error) {
|
|
config := &LandingConfig{
|
|
Enabled: true,
|
|
Template: "open-source-hero",
|
|
}
|
|
|
|
if err := yaml.Unmarshal(content, config); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Apply defaults
|
|
if config.Template == "" {
|
|
config.Template = "open-source-hero"
|
|
}
|
|
if config.Theme.Mode == "" {
|
|
config.Theme.Mode = "auto"
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// HashConfig returns a SHA256 hash of the config content for change detection
|
|
func HashConfig(content []byte) string {
|
|
hash := sha256.Sum256(content)
|
|
return hex.EncodeToString(hash[:])
|
|
}
|
|
|
|
// DefaultConfig returns a default landing page configuration
|
|
func DefaultConfig() *LandingConfig {
|
|
return &LandingConfig{
|
|
Enabled: true,
|
|
Template: "open-source-hero",
|
|
Hero: HeroConfig{
|
|
Headline: "Build something amazing",
|
|
Subheadline: "A powerful toolkit for developers who want to ship fast.",
|
|
PrimaryCTA: CTAButton{
|
|
Label: "Get Started",
|
|
URL: "#",
|
|
},
|
|
SecondaryCTA: CTAButton{
|
|
Label: "View on GitHub",
|
|
URL: "#",
|
|
},
|
|
},
|
|
Stats: []StatConfig{
|
|
{Value: "10k+", Label: "Downloads"},
|
|
{Value: "100+", Label: "Contributors"},
|
|
{Value: "MIT", Label: "License"},
|
|
},
|
|
ValueProps: []ValuePropConfig{
|
|
{Title: "Fast", Description: "Optimized for performance out of the box.", Icon: "zap"},
|
|
{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.",
|
|
Button: CTAButton{
|
|
Label: "Get Started Free",
|
|
URL: "#",
|
|
},
|
|
},
|
|
Footer: FooterConfig{
|
|
ShowPoweredBy: true,
|
|
},
|
|
Theme: ThemeConfig{
|
|
Mode: "auto",
|
|
},
|
|
}
|
|
}
|
|
|
|
// ValidTemplates returns the list of valid template names
|
|
func ValidTemplates() []string {
|
|
return []string{"open-source-hero", "minimalist-docs", "saas-conversion", "bold-marketing"}
|
|
}
|
|
|
|
// IsValidTemplate checks if a template name is valid
|
|
func IsValidTemplate(name string) bool {
|
|
return slices.Contains(ValidTemplates(), name)
|
|
}
|
|
|
|
// TemplateDisplayNames returns a map of template names to display names
|
|
func TemplateDisplayNames() map[string]string {
|
|
return map[string]string{
|
|
"open-source-hero": "Open Source Hero",
|
|
"minimalist-docs": "Minimalist Docs",
|
|
"saas-conversion": "SaaS Conversion",
|
|
"bold-marketing": "Bold Marketing",
|
|
}
|
|
}
|