2
0

2 Commits

Author SHA1 Message Date
416278c747 fix(pages): allow cross-promotion to private repos with pages
Some checks failed
Build and Release / Create Release (push) Successful in 0s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 4m54s
Build and Release / Unit Tests (push) Successful in 5m29s
Build and Release / Lint (push) Successful in 7m53s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 4m43s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 5m3s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h4m41s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Successful in 4m26s
Build and Release / Build Binary (linux/arm64) (push) Failing after 15m15s
Remove the private repo filter from cross-promotion targets. Landing pages are publicly accessible regardless of the repository's private flag, so private repos with pages enabled should be allowed as cross-promotion targets.
2026-04-24 23:26:16 -04:00
58cf5dd410 fix(pages): strip AI placeholder values from translations
Filter out placeholder strings like "<UNKNOWN>", "<EMPTY>", and "N/A" that AI sometimes returns for empty fields. Only include non-empty fields in the translatable content to prevent placeholders from being generated and saved in translation overlays.
2026-04-24 23:24:01 -04:00
3 changed files with 144 additions and 71 deletions

View File

@@ -301,11 +301,9 @@ func renderLandingPage(ctx *context.Context, repo *repo_model.Repository, config
log.Trace("Cross-promote target repo %d not found", r.TargetRepoID)
continue
}
if target.IsPrivate {
log.Trace("Cross-promote target %s is private, skipping", target.FullName())
continue
}
// Only include repos that have landing pages enabled (check DB flag)
// Only require that the target repo has landing pages enabled —
// the landing page is publicly reachable independent of the repo's
// private flag, so we don't filter by target.IsPrivate.
pagesEnabled, err := repo_model.IsPagesEnabled(ctx, target.ID)
if err != nil || !pagesEnabled {
log.Trace("Cross-promote target %s does not have pages enabled (err=%v, enabled=%v)", target.FullName(), err, pagesEnabled)

View File

@@ -209,12 +209,55 @@ func TranslateLandingPageContent(ctx context.Context, repo *repo_model.Repositor
// Validate it's valid JSON
result := extractJSON(resp.Result)
var check map[string]any
if err := json.Unmarshal([]byte(result), &check); err != nil {
var parsed map[string]any
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
return "", fmt.Errorf("AI returned invalid JSON: %w", err)
}
return result, nil
// Strip placeholder values the AI sometimes returns for empty inputs
// (e.g. "<UNKNOWN>", "<EMPTY>", "N/A") so they don't end up saved as translations.
stripPlaceholders(parsed)
cleaned, err := json.Marshal(parsed)
if err != nil {
return "", fmt.Errorf("re-marshal translation: %w", err)
}
return string(cleaned), nil
}
// stripPlaceholders walks a translation overlay and removes string values that
// look like AI placeholders for empty input (e.g. "<UNKNOWN>", "<EMPTY>", "N/A").
// This keeps stale "<UNKNOWN>" strings out of the saved translation overlay.
func stripPlaceholders(v any) {
switch t := v.(type) {
case map[string]any:
for k, child := range t {
if s, ok := child.(string); ok && isPlaceholder(s) {
delete(t, k)
continue
}
stripPlaceholders(child)
}
case []any:
for _, item := range t {
stripPlaceholders(item)
}
}
}
func isPlaceholder(s string) bool {
switch strings.TrimSpace(strings.ToUpper(s)) {
case "<UNKNOWN>", "<EMPTY>", "<NULL>", "N/A", "NONE":
return true
}
return false
}
// setIfNotEmpty assigns v to m[key] only if v is non-empty.
func setIfNotEmpty(m map[string]any, key, v string) {
if v != "" {
m[key] = v
}
}
// buildTranslatableContent extracts translatable text from a config for the AI
@@ -223,18 +266,26 @@ func buildTranslatableContent(config *pages_module.LandingConfig) string {
// Brand
if config.Brand.Name != "" || config.Brand.Tagline != "" {
content["brand"] = map[string]any{
"name": config.Brand.Name,
"tagline": config.Brand.Tagline,
brand := map[string]any{}
setIfNotEmpty(brand, "name", config.Brand.Name)
setIfNotEmpty(brand, "tagline", config.Brand.Tagline)
if len(brand) > 0 {
content["brand"] = brand
}
}
// Hero
content["hero"] = map[string]any{
"headline": config.Hero.Headline,
"subheadline": config.Hero.Subheadline,
"primary_cta": map[string]string{"label": config.Hero.PrimaryCTA.Label},
"secondary_cta": map[string]string{"label": config.Hero.SecondaryCTA.Label},
// Hero — only include non-empty fields so the AI doesn't return "<UNKNOWN>"
hero := map[string]any{}
setIfNotEmpty(hero, "headline", config.Hero.Headline)
setIfNotEmpty(hero, "subheadline", config.Hero.Subheadline)
if config.Hero.PrimaryCTA.Label != "" {
hero["primary_cta"] = map[string]string{"label": config.Hero.PrimaryCTA.Label}
}
if config.Hero.SecondaryCTA.Label != "" {
hero["secondary_cta"] = map[string]string{"label": config.Hero.SecondaryCTA.Label}
}
if len(hero) > 0 {
content["hero"] = hero
}
// Stats, Value Props, Features (already struct-serializable)
@@ -264,39 +315,58 @@ func buildTranslatableContent(config *pages_module.LandingConfig) string {
if len(config.SocialProof.Testimonials) > 0 {
testimonials := make([]map[string]string, 0, len(config.SocialProof.Testimonials))
for _, t := range config.SocialProof.Testimonials {
testimonials = append(testimonials, map[string]string{
"quote": t.Quote,
"role": t.Role,
})
tm := map[string]string{}
if t.Quote != "" {
tm["quote"] = t.Quote
}
if t.Role != "" {
tm["role"] = t.Role
}
if len(tm) > 0 {
testimonials = append(testimonials, tm)
}
}
if len(testimonials) > 0 {
content["social_proof"] = map[string]any{"testimonials": testimonials}
}
content["social_proof"] = map[string]any{"testimonials": testimonials}
}
// Pricing
if config.Pricing.Headline != "" || len(config.Pricing.Plans) > 0 {
pricing := map[string]any{
"headline": config.Pricing.Headline,
"subheadline": config.Pricing.Subheadline,
}
pricing := map[string]any{}
setIfNotEmpty(pricing, "headline", config.Pricing.Headline)
setIfNotEmpty(pricing, "subheadline", config.Pricing.Subheadline)
if len(config.Pricing.Plans) > 0 {
plans := make([]map[string]string, 0, len(config.Pricing.Plans))
for _, p := range config.Pricing.Plans {
plans = append(plans, map[string]string{
"name": p.Name,
"period": p.Period,
"cta": p.CTA,
})
plan := map[string]string{}
if p.Name != "" {
plan["name"] = p.Name
}
if p.Period != "" {
plan["period"] = p.Period
}
if p.CTA != "" {
plan["cta"] = p.CTA
}
plans = append(plans, plan)
}
pricing["plans"] = plans
}
content["pricing"] = pricing
if len(pricing) > 0 {
content["pricing"] = pricing
}
}
// CTA Section
content["cta_section"] = map[string]any{
"headline": config.CTASection.Headline,
"subheadline": config.CTASection.Subheadline,
"button": map[string]string{"label": config.CTASection.Button.Label},
cta := map[string]any{}
setIfNotEmpty(cta, "headline", config.CTASection.Headline)
setIfNotEmpty(cta, "subheadline", config.CTASection.Subheadline)
if config.CTASection.Button.Label != "" {
cta["button"] = map[string]string{"label": config.CTASection.Button.Label}
}
if len(cta) > 0 {
content["cta_section"] = cta
}
// Blog
@@ -306,9 +376,9 @@ func buildTranslatableContent(config *pages_module.LandingConfig) string {
blogHeadline = "Latest Posts"
}
blog := map[string]any{
"headline": blogHeadline,
"subheadline": config.Blog.Subheadline,
"headline": blogHeadline,
}
setIfNotEmpty(blog, "subheadline", config.Blog.Subheadline)
if config.Blog.CTAButton.Label != "" {
blog["cta_button"] = map[string]string{"label": config.Blog.CTAButton.Label}
}
@@ -321,10 +391,11 @@ func buildTranslatableContent(config *pages_module.LandingConfig) string {
if galleryHeadline == "" {
galleryHeadline = "Gallery"
}
content["gallery"] = map[string]any{
"headline": galleryHeadline,
"subheadline": config.Gallery.Subheadline,
gallery := map[string]any{
"headline": galleryHeadline,
}
setIfNotEmpty(gallery, "subheadline", config.Gallery.Subheadline)
content["gallery"] = gallery
}
// Comparison
@@ -333,10 +404,11 @@ func buildTranslatableContent(config *pages_module.LandingConfig) string {
if compHeadline == "" {
compHeadline = "How We Compare"
}
content["comparison"] = map[string]any{
"headline": compHeadline,
"subheadline": config.Comparison.Subheadline,
comparison := map[string]any{
"headline": compHeadline,
}
setIfNotEmpty(comparison, "subheadline", config.Comparison.Subheadline)
content["comparison"] = comparison
}
// Cross-Promote
@@ -345,32 +417,40 @@ func buildTranslatableContent(config *pages_module.LandingConfig) string {
if cpHeadline == "" {
cpHeadline = "Related Offerings"
}
content["cross_promote"] = map[string]any{
"headline": cpHeadline,
"subheadline": config.CrossPromote.Subheadline,
crossPromote := map[string]any{
"headline": cpHeadline,
}
setIfNotEmpty(crossPromote, "subheadline", config.CrossPromote.Subheadline)
content["cross_promote"] = crossPromote
}
// Footer
if config.Footer.Copyright != "" || len(config.Footer.Links) > 0 {
footer := map[string]any{
"copyright": config.Footer.Copyright,
}
footer := map[string]any{}
setIfNotEmpty(footer, "copyright", config.Footer.Copyright)
if len(config.Footer.Links) > 0 {
links := make([]map[string]string, 0, len(config.Footer.Links))
for _, l := range config.Footer.Links {
links = append(links, map[string]string{"label": l.Label})
if l.Label != "" {
links = append(links, map[string]string{"label": l.Label})
}
}
if len(links) > 0 {
footer["links"] = links
}
footer["links"] = links
}
content["footer"] = footer
if len(footer) > 0 {
content["footer"] = footer
}
}
// SEO
if config.SEO.Title != "" || config.SEO.Description != "" {
content["seo"] = map[string]any{
"title": config.SEO.Title,
"description": config.SEO.Description,
seo := map[string]any{}
setIfNotEmpty(seo, "title", config.SEO.Title)
setIfNotEmpty(seo, "description", config.SEO.Description)
if len(seo) > 0 {
content["seo"] = seo
}
}

View File

@@ -37,21 +37,6 @@
</div>
</div>
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.section_labels"}}</h5>
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.section_labels_desc"}}</p>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.label_value_props"}}</label>
<input name="label_value_props" value="{{.Config.Navigation.LabelValueProps}}" placeholder="Why choose us">
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.label_value_props_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.label_features"}}</label>
<input name="label_features" value="{{.Config.Navigation.LabelFeatures}}" placeholder="Capabilities">
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.label_features_help"}}</p>
</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">
@@ -174,6 +159,11 @@
<button type="button" class="ui mini button" onclick="addStat()">+ Add Stat</button>
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.value_props"}}</h5>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.label_value_props"}}</label>
<input name="label_value_props" value="{{.Config.Navigation.LabelValueProps}}" placeholder="Why choose us">
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.label_value_props_help"}}</p>
</div>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.value_props_headline"}}</label>
@@ -252,6 +242,11 @@
<button type="button" class="ui mini button" onclick="addValueProp()">+ Add Value Prop</button>
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.features"}}</h5>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.label_features"}}</label>
<input name="label_features" value="{{.Config.Navigation.LabelFeatures}}" placeholder="Capabilities">
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.label_features_help"}}</p>
</div>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.features_headline"}}</label>