-
Notifications
You must be signed in to change notification settings - Fork 191
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ feat: textutil - add StrTemplate for quick render template string
- Loading branch information
Showing
4 changed files
with
372 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
package textutil | ||
|
||
import ( | ||
"bytes" | ||
"io" | ||
"strings" | ||
"text/template" | ||
|
||
"github.com/gookit/goutil" | ||
"github.com/gookit/goutil/basefn" | ||
"github.com/gookit/goutil/fsutil" | ||
"github.com/gookit/goutil/strutil" | ||
) | ||
|
||
var builtInFuncs = template.FuncMap{ | ||
// don't escape content | ||
"raw": func(s string) string { | ||
return s | ||
}, | ||
"trim": strings.TrimSpace, | ||
// lower first char | ||
"lcFirst": strutil.LowerFirst, | ||
// upper first char | ||
"upFirst": strutil.UpperFirst, | ||
// upper case | ||
"upper": strings.ToUpper, | ||
// lower case | ||
"lower": strings.ToLower, | ||
// cut sub-string | ||
"substr": strutil.Substr, | ||
// default value | ||
"default": func(v, defVal any) string { | ||
if goutil.IsEmpty(v) { | ||
return strutil.SafeString(defVal) | ||
} | ||
return strutil.SafeString(v) | ||
}, | ||
// join strings | ||
"join": func(ss []string, sep string) string { | ||
return strings.Join(ss, sep) | ||
}, | ||
} | ||
|
||
// TextRenderOpt render text template options | ||
type TextRenderOpt struct { | ||
// Output use custom output writer | ||
Output io.Writer | ||
// Funcs add custom template functions | ||
Funcs template.FuncMap | ||
} | ||
|
||
// RenderOptFn render option func | ||
type RenderOptFn func(opt *TextRenderOpt) | ||
|
||
// NewRenderOpt create a new render options | ||
func NewRenderOpt(optFns []RenderOptFn) *TextRenderOpt { | ||
opt := &TextRenderOpt{} | ||
for _, fn := range optFns { | ||
fn(opt) | ||
} | ||
return opt | ||
} | ||
|
||
// RenderGoTpl render input text or template file. | ||
func RenderGoTpl(input string, data any, optFns ...RenderOptFn) string { | ||
opt := NewRenderOpt(optFns) | ||
|
||
t := template.New("text-renderer") | ||
t.Funcs(builtInFuncs) | ||
if len(opt.Funcs) > 0 { | ||
t.Funcs(opt.Funcs) | ||
} | ||
|
||
if !strings.Contains(input, "{{") && fsutil.IsFile(input) { | ||
template.Must(t.ParseFiles(input)) | ||
} else { | ||
template.Must(t.Parse(input)) | ||
} | ||
|
||
// use custom output writer | ||
if opt.Output != nil { | ||
basefn.MustOK(t.Execute(opt.Output, data)) | ||
return "" // return empty string | ||
} | ||
|
||
// use buffer receive rendered content | ||
buf := new(bytes.Buffer) | ||
basefn.MustOK(t.Execute(buf, data)) | ||
return buf.String() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,250 @@ | ||
package textutil | ||
|
||
import ( | ||
"fmt" | ||
"io" | ||
"regexp" | ||
"strings" | ||
"text/template" | ||
|
||
"github.com/gookit/goutil/arrutil" | ||
"github.com/gookit/goutil/basefn" | ||
"github.com/gookit/goutil/fsutil" | ||
"github.com/gookit/goutil/reflects" | ||
"github.com/gookit/goutil/structs" | ||
"github.com/gookit/goutil/strutil" | ||
) | ||
|
||
// STemplateOptFn template option func | ||
type STemplateOptFn func(opt *StrTemplateOpt) | ||
|
||
// StrTemplateOpt template options for StrTemplate | ||
type StrTemplateOpt struct { | ||
// func name alias map. eg: {"up_first": "upFirst"} | ||
nameMp structs.Aliases | ||
Funcs template.FuncMap | ||
|
||
Left, Right string | ||
|
||
ParseDef bool | ||
ParseEnv bool | ||
} | ||
|
||
// StrTemplate implement a simple string template | ||
// | ||
// - support parse template vars | ||
// - support access multi-level map field. eg: {{ user.name }} | ||
// - support parse default value | ||
// - support parse env vars | ||
// - support custom pipeline func handle. eg: {{ name | upper }} {{ name | def:guest }} | ||
// | ||
// NOTE: not support control flow, eg: if/else/for/with | ||
type StrTemplate struct { | ||
StrTemplateOpt | ||
vr VarReplacer | ||
// template func map. refer the text/template | ||
// | ||
// Func allow return 1 or 2 values, if return 2 values, the second value is error. | ||
fxs map[string]*reflects.FuncX | ||
} | ||
|
||
// NewStrTemplate instance | ||
func NewStrTemplate(opFns ...STemplateOptFn) *StrTemplate { | ||
st := &StrTemplate{ | ||
fxs: make(map[string]*reflects.FuncX), | ||
vr: VarReplacer{ | ||
Left: "{{", | ||
Right: "}}", | ||
}, | ||
} | ||
|
||
st.ParseDef = true | ||
st.ParseEnv = true | ||
st.vr.RenderFn = st.renderVars | ||
for _, fn := range opFns { | ||
fn(&st.StrTemplateOpt) | ||
} | ||
|
||
st.Init() | ||
return st | ||
} | ||
|
||
// Init StrTemplate | ||
func (t *StrTemplate) Init() { | ||
if t.vr.init { | ||
return | ||
} | ||
|
||
basefn.PanicIf(t.vr.Right == "", "var format Right chars is required") | ||
|
||
t.vr.init = true | ||
t.vr.parseDef = t.ParseDef | ||
t.vr.parseEnv = t.ParseEnv | ||
|
||
t.vr.lLen, t.vr.rLen = len(t.vr.Left), len(t.vr.Right) | ||
// (?s:...) - 让 "." 匹配换行 | ||
// (?s:(.+?)) - 第二个 "?" 非贪婪匹配 | ||
t.vr.varReg = regexp.MustCompile(regexp.QuoteMeta(t.vr.Left) + `(?s:(.+?))` + regexp.QuoteMeta(t.vr.Right)) | ||
|
||
// add built-in funcs | ||
t.AddFuncs(builtInFuncs) | ||
t.nameMp.AddAliasMap(map[string]string{ | ||
"up_first": "upFirst", | ||
"lc_first": "lcFirst", | ||
"def": "default", | ||
}) | ||
} | ||
|
||
// AddFuncs add custom template functions | ||
func (t *StrTemplate) AddFuncs(fns map[string]any) { | ||
for name, fn := range fns { | ||
t.fxs[name] = reflects.NewFunc(fn) | ||
} | ||
} | ||
|
||
// RenderString render template string with vars | ||
func (t *StrTemplate) RenderString(s string, vars map[string]any) string { | ||
return t.vr.Replace(s, vars) | ||
} | ||
|
||
// RenderFile render template file with vars | ||
func (t *StrTemplate) RenderFile(filePath string, vars map[string]any) (string, error) { | ||
if !fsutil.FileExists(filePath) { | ||
return "", fmt.Errorf("template file not exists: %s", filePath) | ||
} | ||
|
||
// read file contents | ||
s, err := fsutil.ReadStringOrErr(filePath) | ||
if err != nil { | ||
return "", err | ||
} | ||
|
||
return t.vr.Replace(s, vars), nil | ||
} | ||
|
||
// RenderWrite render template string with vars, and write to writer | ||
func (t *StrTemplate) RenderWrite(wr io.Writer, s string, vars map[string]any) error { | ||
s = t.vr.Replace(s, vars) | ||
_, err := io.WriteString(wr, s) | ||
return err | ||
} | ||
|
||
func (t *StrTemplate) renderVars(s string, varMap map[string]string) string { | ||
return t.vr.varReg.ReplaceAllStringFunc(s, func(sub string) string { | ||
// var name or pipe expression. | ||
name := strings.TrimSpace(sub[t.vr.lLen : len(sub)-t.vr.rLen]) | ||
name = strings.TrimLeft(name, ".") | ||
|
||
var defVal string | ||
var pipes []string | ||
if strings.ContainsRune(name, '|') { | ||
pipes = strutil.Split(name, "|") | ||
// compatible default value. eg: {{ name | inhere }} | ||
if len(pipes) == 2 && !strings.ContainsRune(pipes[1], ':') && !t.isFunc(pipes[1]) { | ||
name, defVal = pipes[0], pipes[1] | ||
pipes = nil // clear pipes | ||
} else { // collect pipe functions | ||
name, pipes = pipes[0], pipes[1:] | ||
} | ||
} | ||
|
||
if val, ok := varMap[name]; ok { | ||
if len(pipes) > 0 { | ||
var err error | ||
val, err = t.applyPipes(val, pipes) | ||
if err != nil { | ||
return fmt.Sprintf("Render var %q error: %v", name, err) | ||
} | ||
} | ||
return val | ||
} | ||
|
||
// var not found | ||
if len(defVal) > 0 { | ||
return defVal | ||
} | ||
|
||
if t.vr.NotFound != nil { | ||
if val, ok := t.vr.NotFound(name); ok { | ||
return val | ||
} | ||
} | ||
|
||
// check is default func. eg: {{ name | def:guest }} | ||
if len(pipes) == 1 && strings.ContainsRune(pipes[0], ':') { | ||
fName, argVal := strutil.TrimCut(pipes[0], ":") | ||
if t.isDefaultFunc(fName) { | ||
return argVal | ||
} | ||
} | ||
|
||
t.vr.missVars = append(t.vr.missVars, name) | ||
return sub | ||
}) | ||
} | ||
|
||
func (t *StrTemplate) applyPipes(val any, pipes []string) (string, error) { | ||
var err error | ||
|
||
// pipe expr: "trim|upper|substr:1,2" | ||
// => | ||
// pipes: ["trim", "upper", "substr:1,2"] | ||
for _, name := range pipes { | ||
args := []any{val} | ||
|
||
// has custom args. eg: "substr:1,2" | ||
if strings.ContainsRune(name, ':') { | ||
var argStr string | ||
name, argStr = strutil.TrimCut(name, ":") | ||
|
||
if otherArgs := parseArgStr(argStr); len(otherArgs) > 0 { | ||
args = append(args, otherArgs...) | ||
} | ||
} | ||
|
||
name = t.nameMp.ResolveAlias(name) | ||
|
||
// call pipe func | ||
if fx, ok := t.fxs[name]; ok { | ||
val, err = fx.Call2(args...) | ||
if err != nil { | ||
return "", err | ||
} | ||
} else { | ||
return "", fmt.Errorf("template func %q not found", name) | ||
} | ||
} | ||
|
||
return strutil.ToString(val) | ||
} | ||
|
||
func (t *StrTemplate) isFunc(name string) bool { | ||
_, ok := t.fxs[name] | ||
if !ok { | ||
// check name alias | ||
return t.nameMp.HasAlias(name) | ||
} | ||
return ok | ||
} | ||
|
||
func (t *StrTemplate) isDefaultFunc(name string) bool { | ||
return name == "default" || name == "def" | ||
} | ||
|
||
var stdTpl = NewStrTemplate() | ||
|
||
// RenderString render str template string or file. | ||
func RenderString(input string, data map[string]any, optFns ...RenderOptFn) string { | ||
return stdTpl.RenderString(input, data) | ||
} | ||
|
||
func parseArgStr(argStr string) (ss []any) { | ||
if argStr == "" { // no arg | ||
return | ||
} | ||
|
||
if len(argStr) == 1 { // one char | ||
return []any{argStr} | ||
} | ||
return arrutil.StringsToAnys(strutil.Split(argStr, ",")) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
package textutil_test | ||
|
||
import ( | ||
"testing" | ||
|
||
"github.com/gookit/goutil/strutil/textutil" | ||
"github.com/gookit/goutil/testutil/assert" | ||
) | ||
|
||
func TestRenderString(t *testing.T) { | ||
data := map[string]any{ | ||
"name": "inhere", | ||
"age": 2000, | ||
} | ||
|
||
t.Run("basic", func(t *testing.T) { | ||
tpl := "hi, My name is {{ .name | upFirst }}, age is {{ .age }}" | ||
str := textutil.RenderString(tpl, data) | ||
assert.Eq(t, "hi, My name is Inhere, age is 2000", str) | ||
}) | ||
|
||
// with default value and alias func | ||
t.Run("with default value and alias func", func(t *testing.T) { | ||
tpl := "name: {{ .name | default:guest }}, age: {{ .age | def:18 }}, city: {{ .city | cd }}" | ||
str := textutil.RenderString(tpl, map[string]any{}) | ||
assert.Eq(t, "name: guest, age: 18, city: cd", str) | ||
}) | ||
} |
Oops, something went wrong.