Skip to content

Commit 3a3d649

Browse files
committed
(#13) Add color markup support for form descriptions
Add colorMarkup function that parses color tags like {red}text{/red} and applies colors using go-pretty/text package. Supports all standard and high-intensity colors, nested tags, case-insensitive color names, and graceful handling of invalid colors. Updates both form descriptions and property descriptions to support color markup when rendered to screen.
1 parent 8312afb commit 3a3d649

File tree

6 files changed

+237
-5
lines changed

6 files changed

+237
-5
lines changed

forms/forms.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func (p *Property) RenderedDescription(env map[string]any) (string, error) {
6363
return "", err
6464
}
6565

66-
return buffer.String(), nil
66+
return colorMarkup(buffer.String()), nil
6767
}
6868

6969
type processor struct {

forms/util.go

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ package forms
66

77
import (
88
"bytes"
9-
"github.com/choria-io/scaffold/internal/sprig"
109
"os"
10+
"strings"
1111
"text/template"
1212

13+
"github.com/choria-io/scaffold/internal/sprig"
14+
"github.com/jedib0t/go-pretty/v6/text"
15+
1316
"github.com/AlecAivazis/survey/v2"
1417
terminal "golang.org/x/term"
1518
)
@@ -61,5 +64,95 @@ func renderTemplate(tmpl string, env map[string]any) (string, error) {
6164
return "", err
6265
}
6366

64-
return out.String(), nil
67+
return colorMarkup(out.String()), nil
68+
}
69+
70+
// colorMarkup parses a string with color markup tags and returns a colorized string.
71+
// Supports tags like {red}text{/red}, {blue}text{/blue}, etc.
72+
// Supports all colors available in the go-pretty/text package.
73+
// Supports nested and multiple color tags in a single string.
74+
func colorMarkup(input string) string {
75+
colorMap := map[string]text.Color{
76+
"bold": text.Bold,
77+
"black": text.FgBlack,
78+
"red": text.FgRed,
79+
"green": text.FgGreen,
80+
"yellow": text.FgYellow,
81+
"blue": text.FgBlue,
82+
"magenta": text.FgMagenta,
83+
"cyan": text.FgCyan,
84+
"white": text.FgWhite,
85+
"hiblack": text.FgHiBlack,
86+
"hired": text.FgHiRed,
87+
"higreen": text.FgHiGreen,
88+
"hiyellow": text.FgHiYellow,
89+
"hiblue": text.FgHiBlue,
90+
"himagenta": text.FgHiMagenta,
91+
"hicyan": text.FgHiCyan,
92+
"hiwhite": text.FgHiWhite,
93+
}
94+
95+
// Process innermost tags first to handle nesting properly
96+
result := input
97+
for {
98+
changed := false
99+
100+
// Find innermost color tag (one that doesn't contain other opening tags)
101+
for i := 0; i < len(result); i++ {
102+
if result[i] != '{' {
103+
continue
104+
}
105+
106+
// Find the end of this opening tag
107+
closePos := strings.Index(result[i:], "}")
108+
if closePos == -1 {
109+
continue
110+
}
111+
closePos += i
112+
113+
colorName := result[i+1 : closePos]
114+
115+
// Skip if this contains a slash (it's a closing tag)
116+
if strings.Contains(colorName, "/") {
117+
continue
118+
}
119+
120+
// Find the corresponding closing tag
121+
closeTag := "{/" + colorName + "}"
122+
closeStart := strings.Index(result[closePos+1:], closeTag)
123+
if closeStart == -1 {
124+
continue
125+
}
126+
closeStart += closePos + 1
127+
128+
// Check if this is innermost (no opening tags in between)
129+
content := result[closePos+1 : closeStart]
130+
if strings.Contains(content, "{") && !strings.HasPrefix(strings.TrimSpace(content[strings.Index(content, "{"):]), "/") {
131+
// This contains other opening tags, skip for now
132+
continue
133+
}
134+
135+
// This is an innermost tag, process it
136+
fullMatch := result[i : closeStart+len(closeTag)]
137+
138+
colorNameLower := strings.ToLower(colorName)
139+
if color, exists := colorMap[colorNameLower]; exists {
140+
coloredText := text.Colors{color}.Sprint(content)
141+
result = strings.Replace(result, fullMatch, coloredText, 1)
142+
changed = true
143+
break
144+
} else {
145+
// If color doesn't exist, just remove the tags
146+
result = strings.Replace(result, fullMatch, content, 1)
147+
changed = true
148+
break
149+
}
150+
}
151+
152+
if !changed {
153+
break
154+
}
155+
}
156+
157+
return result
65158
}

forms/util_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) 2023-2024, R.I. Pienaar and the Choria Project contributors
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
package forms
6+
7+
import (
8+
"strings"
9+
"testing"
10+
11+
"github.com/jedib0t/go-pretty/v6/text"
12+
)
13+
14+
func TestColorMarkup(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
input string
18+
expected string
19+
}{
20+
{
21+
name: "no color markup",
22+
input: "Hello World",
23+
expected: "Hello World",
24+
},
25+
{
26+
name: "single color tag",
27+
input: "{red}Hello{/red} World",
28+
expected: text.Colors{text.FgRed}.Sprint("Hello") + " World",
29+
},
30+
{
31+
name: "multiple color tags",
32+
input: "{red}Hello{/red} {blue}World{/blue}",
33+
expected: text.Colors{text.FgRed}.Sprint("Hello") + " " + text.Colors{text.FgBlue}.Sprint("World"),
34+
},
35+
{
36+
name: "nested color tags",
37+
input: "{red}Outer {green}Inner{/green} Text{/red}",
38+
expected: text.Colors{text.FgRed}.Sprint("Outer " + text.Colors{text.FgGreen}.Sprint("Inner") + " Text"),
39+
},
40+
{
41+
name: "case insensitive colors",
42+
input: "{RED}Hello{/RED} {Blue}World{/Blue}",
43+
expected: text.Colors{text.FgRed}.Sprint("Hello") + " " + text.Colors{text.FgBlue}.Sprint("World"),
44+
},
45+
{
46+
name: "high intensity colors",
47+
input: "{hired}Error{/hired} {higreen}Success{/higreen}",
48+
expected: text.Colors{text.FgHiRed}.Sprint("Error") + " " + text.Colors{text.FgHiGreen}.Sprint("Success"),
49+
},
50+
{
51+
name: "invalid color removes tags",
52+
input: "{invalid}Text{/invalid}",
53+
expected: "Text",
54+
},
55+
{
56+
name: "mixed valid and invalid colors",
57+
input: "{red}Valid{/red} {invalid}Invalid{/invalid} {blue}Another{/blue}",
58+
expected: text.Colors{text.FgRed}.Sprint("Valid") + " Invalid " + text.Colors{text.FgBlue}.Sprint("Another"),
59+
},
60+
{
61+
name: "empty color tag",
62+
input: "{red}{/red}",
63+
expected: text.Colors{text.FgRed}.Sprint(""),
64+
},
65+
{
66+
name: "all standard colors",
67+
input: "{black}black{/black} {red}red{/red} {green}green{/green} {yellow}yellow{/yellow} {blue}blue{/blue} {magenta}magenta{/magenta} {cyan}cyan{/cyan} {white}white{/white}",
68+
expected: text.Colors{text.FgBlack}.Sprint("black") + " " +
69+
text.Colors{text.FgRed}.Sprint("red") + " " +
70+
text.Colors{text.FgGreen}.Sprint("green") + " " +
71+
text.Colors{text.FgYellow}.Sprint("yellow") + " " +
72+
text.Colors{text.FgBlue}.Sprint("blue") + " " +
73+
text.Colors{text.FgMagenta}.Sprint("magenta") + " " +
74+
text.Colors{text.FgCyan}.Sprint("cyan") + " " +
75+
text.Colors{text.FgWhite}.Sprint("white"),
76+
},
77+
}
78+
79+
for _, tt := range tests {
80+
t.Run(tt.name, func(t *testing.T) {
81+
result := colorMarkup(tt.input)
82+
if result != tt.expected {
83+
t.Errorf("colorMarkup() = %q, want %q", result, tt.expected)
84+
// Also show without ANSI codes for easier debugging
85+
t.Errorf("colorMarkup() (no ANSI) = %q, want (no ANSI) = %q",
86+
stripANSI(result), stripANSI(tt.expected))
87+
}
88+
})
89+
}
90+
}
91+
92+
func TestColorMarkupComplexNesting(t *testing.T) {
93+
// Test that our regex handles the innermost tags first
94+
input := "{red}Start {blue}Middle {green}End{/green} More{/blue} Final{/red}"
95+
result := colorMarkup(input)
96+
97+
// The function should process innermost tags first, so {green}End{/green} gets processed first
98+
// This is a complex case that tests the iterative processing
99+
if !strings.Contains(result, "Start") || !strings.Contains(result, "Middle") ||
100+
!strings.Contains(result, "End") || !strings.Contains(result, "More") ||
101+
!strings.Contains(result, "Final") {
102+
t.Errorf("Complex nesting failed to preserve all text content")
103+
}
104+
}
105+
106+
// Helper function to strip ANSI escape codes for easier test comparison
107+
func stripANSI(s string) string {
108+
// Simple ANSI escape sequence remover for testing
109+
result := ""
110+
inEscape := false
111+
112+
for i, r := range s {
113+
if r == '\033' && i+1 < len(s) && s[i+1] == '[' {
114+
inEscape = true
115+
continue
116+
}
117+
if inEscape && (r == 'm' || r == 'K') {
118+
inEscape = false
119+
continue
120+
}
121+
if !inEscape {
122+
result += string(r)
123+
}
124+
}
125+
return result
126+
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,13 @@ require (
2727
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
2828
github.com/google/go-cmp v0.7.0 // indirect
2929
github.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect
30+
github.com/jedib0t/go-pretty/v6 v6.6.7 // indirect
3031
github.com/mattn/go-colorable v0.1.14 // indirect
3132
github.com/mattn/go-isatty v0.0.20 // indirect
33+
github.com/mattn/go-runewidth v0.0.16 // indirect
3234
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
3335
github.com/mitchellh/reflectwalk v1.0.2 // indirect
36+
github.com/rivo/uniseg v0.4.7 // indirect
3437
go.uber.org/automaxprocs v1.6.0 // indirect
3538
golang.org/x/net v0.40.0 // indirect
3639
golang.org/x/sys v0.33.0 // indirect

go.sum

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u
3333
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
3434
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
3535
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
36+
github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo=
37+
github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
3638
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
3739
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
3840
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -45,6 +47,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg
4547
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
4648
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
4749
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
50+
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
51+
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
4852
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
4953
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
5054
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
@@ -60,6 +64,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
6064
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
6165
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
6266
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
67+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
68+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
69+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
6370
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
6471
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
6572
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=

scaffold.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import (
88
"bytes"
99
"errors"
1010
"fmt"
11-
"github.com/choria-io/scaffold/internal/sprig"
12-
"github.com/kballard/go-shellquote"
1311
"io/fs"
1412
"os"
1513
"os/exec"
1614
"path/filepath"
1715
"strings"
1816
"text/template"
17+
18+
"github.com/choria-io/scaffold/internal/sprig"
19+
"github.com/kballard/go-shellquote"
1920
)
2021

2122
// Config configures a scaffolding operation
@@ -24,6 +25,8 @@ type Config struct {
2425
TargetDirectory string `yaml:"target"`
2526
// SourceDirectory reads templates from a directory, mutually exclusive with Source
2627
SourceDirectory string `yaml:"source_directory"`
28+
// MergeTargetDirectory writes into existing target directories
29+
MergeTargetDirectory bool `yaml:"merge_target_directory"`
2730
// Source reads templates from in-process memory
2831
Source map[string]any `yaml:"source"`
2932
// Post configures post-processing of files using filepath globs

0 commit comments

Comments
 (0)