diff --git a/plugins/content/text/impl.go b/plugins/content/text/impl.go deleted file mode 100644 index cbe49809..00000000 --- a/plugins/content/text/impl.go +++ /dev/null @@ -1,51 +0,0 @@ -package text - -import ( - "bytes" - "fmt" - "text/template" - - "github.com/blackstork-io/fabric/pkg/jsontools" - "github.com/blackstork-io/fabric/plugins/content" -) - -// Actual implementation of the plugin - -type Impl struct{} - -var _ content.Plugin = (*Impl)(nil) - -const PluginName = "content.text" - -func (Impl) Execute(attrsRaw, dictRaw any) (resp string, err error) { - var attrs struct { - Text string `json:"text"` - } - var dict any - err = jsontools.UnmarshalBytes(attrsRaw, &attrs) - if err != nil { - return - } - err = jsontools.UnmarshalBytes(dictRaw, &dict) - if err != nil { - return - } - - tmpl, err := template.New(PluginName).Parse(attrs.Text) - if err != nil { - err = fmt.Errorf("failed to parse the template: %w; template: `%s`", err, attrs.Text) - return - } - - var buf bytes.Buffer - buf.WriteString(PluginName) - buf.WriteByte(':') - - err = tmpl.Execute(&buf, dict) - if err != nil { - err = fmt.Errorf("failed to execute the template: %w; template: `%s`; dict: `%s`", err, attrs.Text, jsontools.Dump(dict)) - return - } - resp = buf.String() - return -} diff --git a/plugins/content/text/plugin.go b/plugins/content/text/plugin.go new file mode 100644 index 00000000..decccb70 --- /dev/null +++ b/plugins/content/text/plugin.go @@ -0,0 +1,156 @@ +package text + +import ( + "bytes" + "errors" + "fmt" + "slices" + "strings" + "text/template" + + "github.com/Masterminds/semver/v3" + "github.com/blackstork-io/fabric/plugininterface/v1" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hcldec" + "github.com/zclconf/go-cty/cty" +) + +var Version = semver.MustParse("0.1.0") +var allowedFormats = []string{"text", "title", "code", "blockquote"} + +const ( + minAbsoluteTitleSize = int64(1) + maxAbsoluteTitleSize = int64(6) + defaultAbsoluteTitleSize = int64(1) + defaultFormat = "text" + defaultCodeLanguage = "" +) + +type Plugin struct{} + +func (Plugin) GetPlugins() []plugininterface.Plugin { + return []plugininterface.Plugin{ + { + Namespace: "blackstork", + Kind: "content", + Name: "text", + Version: plugininterface.Version(*Version), + ConfigSpec: nil, + InvocationSpec: &hcldec.ObjectSpec{ + "text": &hcldec.AttrSpec{ + Name: "text", + Type: cty.String, + Required: true, + }, + "format_as": &hcldec.AttrSpec{ + Name: "format_as", + Type: cty.String, + Required: false, + }, + "absolute_title_size": &hcldec.AttrSpec{ + Name: "absolute_title_size", + Type: cty.Number, + Required: false, + }, + "code_language": &hcldec.AttrSpec{ + Name: "code_language", + Type: cty.String, + Required: false, + }, + }, + }, + } +} + +func (p Plugin) render(args cty.Value, datactx map[string]any) (string, error) { + text := args.GetAttr("text") + if text.IsNull() { + return "", errors.New("text is required") + } + format := args.GetAttr("format_as") + if !format.IsNull() { + if !slices.Contains(allowedFormats, format.AsString()) { + return "", errors.New("format_as must be one of " + strings.Join(allowedFormats, ", ")) + } + } else { + format = cty.StringVal(defaultFormat) + } + absoluteTitleSize := args.GetAttr("absolute_title_size") + if absoluteTitleSize.IsNull() { + absoluteTitleSize = cty.NumberIntVal(defaultAbsoluteTitleSize) + } + titleSize, _ := absoluteTitleSize.AsBigFloat().Int64() + if titleSize < minAbsoluteTitleSize || titleSize > maxAbsoluteTitleSize { + return "", fmt.Errorf("absolute_title_size must be between %d and %d", minAbsoluteTitleSize, maxAbsoluteTitleSize) + } + codeLanguage := args.GetAttr("code_language") + if codeLanguage.IsNull() { + codeLanguage = cty.StringVal(defaultCodeLanguage) + } + switch format.AsString() { + case "text": + return p.renderText(text.AsString(), datactx) + case "title": + return p.renderTitle(text.AsString(), datactx, titleSize) + case "code": + return p.renderCode(text.AsString(), datactx, codeLanguage.AsString()) + case "blockquote": + return p.renderBlockquote(text.AsString(), datactx) + } + panic("unreachable") +} + +func (p Plugin) renderText(text string, datactx map[string]any) (string, error) { + tmpl, err := template.New("text").Parse(text) + if err != nil { + return "", fmt.Errorf("failed to parse text template: %w", err) + } + var buf bytes.Buffer + err = tmpl.Execute(&buf, datactx) + if err != nil { + return "", fmt.Errorf("failed to execute text template: %w", err) + } + return strings.TrimSpace(buf.String()), nil +} + +func (p Plugin) renderTitle(text string, datactx map[string]any, titleSize int64) (string, error) { + text, err := p.renderText(text, datactx) + if err != nil { + return "", err + } + // remove all newlines + text = strings.ReplaceAll(text, "\n", " ") + return strings.Repeat("#", int(titleSize)) + " " + text, nil +} + +func (p Plugin) renderCode(text string, datactx map[string]any, language string) (string, error) { + text, err := p.renderText(text, datactx) + if err != nil { + return "", err + } + return fmt.Sprintf("```%s\n%s\n```", language, text), nil +} + +func (p Plugin) renderBlockquote(text string, datactx map[string]any) (string, error) { + text, err := p.renderText(text, datactx) + if err != nil { + return "", err + } + return "> " + strings.ReplaceAll(text, "\n", "\n> "), nil +} + +func (p Plugin) Call(args plugininterface.Args) plugininterface.Result { + result, err := p.render(args.Args, args.Context) + if err != nil { + return plugininterface.Result{ + Diags: hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Failed to render text", + Detail: err.Error(), + }}, + } + } + return plugininterface.Result{ + Result: result, + } +} diff --git a/plugins/content/text/plugin_test.go b/plugins/content/text/plugin_test.go new file mode 100644 index 00000000..51c4f2a0 --- /dev/null +++ b/plugins/content/text/plugin_test.go @@ -0,0 +1,320 @@ +package text + +import ( + "testing" + + "github.com/blackstork-io/fabric/plugininterface/v1" + "github.com/hashicorp/hcl/v2" + "github.com/stretchr/testify/suite" + "github.com/zclconf/go-cty/cty" +) + +type PluginTestSuite struct { + suite.Suite + plugin plugininterface.PluginRPC +} + +func TestPluginSuite(t *testing.T) { + suite.Run(t, &PluginTestSuite{}) +} + +func (s *PluginTestSuite) SetupSuite() { + s.plugin = Plugin{} +} + +func (s *PluginTestSuite) TestGetPlugins() { + plugins := s.plugin.GetPlugins() + s.Require().Len(plugins, 1, "expected 1 plugin") + got := plugins[0] + s.Equal("text", got.Name) + s.Equal("content", got.Kind) + s.Equal("blackstork", got.Namespace) + s.Equal(Version.String(), got.Version.Cast().String()) + s.Nil(got.ConfigSpec) + s.NotNil(got.InvocationSpec) +} + +func (s *PluginTestSuite) TestCallMissingText() { + args := plugininterface.Args{ + Args: cty.ObjectVal(map[string]cty.Value{ + "text": cty.NullVal(cty.String), + "format_as": cty.NullVal(cty.String), + "absolute_title_size": cty.NullVal(cty.Number), + "code_language": cty.NullVal(cty.String), + }), + Context: map[string]any{ + "name": "World", + }, + } + expected := plugininterface.Result{ + Diags: hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Failed to render text", + Detail: "text is required", + }}, + } + s.Equal(expected, s.plugin.Call(args)) +} +func (s *PluginTestSuite) TestCallText() { + args := plugininterface.Args{ + Args: cty.ObjectVal(map[string]cty.Value{ + "text": cty.StringVal("Hello {{.name}}!"), + "format_as": cty.NullVal(cty.String), + "absolute_title_size": cty.NullVal(cty.Number), + "code_language": cty.NullVal(cty.String), + }), + Context: map[string]any{ + "name": "World", + }, + } + expected := plugininterface.Result{ + Result: "Hello World!", + } + s.Equal(expected, s.plugin.Call(args)) +} + +func (s *PluginTestSuite) TestCallTextNoTemplate() { + args := plugininterface.Args{ + Args: cty.ObjectVal(map[string]cty.Value{ + "text": cty.StringVal("Hello World!"), + "format_as": cty.NullVal(cty.String), + "absolute_title_size": cty.NullVal(cty.Number), + "code_language": cty.NullVal(cty.String), + }), + Context: nil, + } + expected := plugininterface.Result{ + Result: "Hello World!", + } + s.Equal(expected, s.plugin.Call(args)) +} + +func (s *PluginTestSuite) TestCallTitleDefault() { + args := plugininterface.Args{ + Args: cty.ObjectVal(map[string]cty.Value{ + "text": cty.StringVal("Hello {{.name}}!"), + "format_as": cty.StringVal("title"), + "absolute_title_size": cty.NullVal(cty.Number), + "code_language": cty.NullVal(cty.String), + }), + Context: map[string]any{ + "name": "World", + }, + } + expected := plugininterface.Result{ + Result: "# Hello World!", + } + s.Equal(expected, s.plugin.Call(args)) +} + +func (s *PluginTestSuite) TestCallTitleWithTextMultiline() { + args := plugininterface.Args{ + Args: cty.ObjectVal(map[string]cty.Value{ + "text": cty.StringVal("Hello\n{{.name}}\nfor you!"), + "format_as": cty.StringVal("title"), + "absolute_title_size": cty.NullVal(cty.Number), + "code_language": cty.NullVal(cty.String), + }), + Context: map[string]any{ + "name": "World", + }, + } + expected := plugininterface.Result{ + Result: "# Hello World for you!", + } + s.Equal(expected, s.plugin.Call(args)) +} + +func (s *PluginTestSuite) TestCallTitleWithSize() { + args := plugininterface.Args{ + Args: cty.ObjectVal(map[string]cty.Value{ + "text": cty.StringVal("Hello {{.name}}!"), + "format_as": cty.StringVal("title"), + "absolute_title_size": cty.NumberIntVal(3), + "code_language": cty.NullVal(cty.String), + }), + Context: map[string]any{ + "name": "World", + }, + } + expected := plugininterface.Result{ + Result: "### Hello World!", + } + s.Equal(expected, s.plugin.Call(args)) +} + +func (s *PluginTestSuite) TestCallTitleWithSizeTooSmall() { + args := plugininterface.Args{ + Args: cty.ObjectVal(map[string]cty.Value{ + "text": cty.StringVal("Hello {{.name}}!"), + "format_as": cty.StringVal("title"), + "absolute_title_size": cty.NumberIntVal(0), + "code_language": cty.NullVal(cty.String), + }), + Context: map[string]any{ + "name": "World", + }, + } + expected := plugininterface.Result{ + Diags: hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Failed to render text", + Detail: "absolute_title_size must be between 1 and 6", + }}, + } + s.Equal(expected, s.plugin.Call(args)) +} + +func (s *PluginTestSuite) TestCallTitleWithSizeTooBig() { + args := plugininterface.Args{ + Args: cty.ObjectVal(map[string]cty.Value{ + "text": cty.StringVal("Hello {{.name}}!"), + "format_as": cty.StringVal("title"), + "absolute_title_size": cty.NumberIntVal(7), + "code_language": cty.NullVal(cty.String), + }), + Context: map[string]any{ + "name": "World", + }, + } + expected := plugininterface.Result{ + Diags: hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Failed to render text", + Detail: "absolute_title_size must be between 1 and 6", + }}, + } + s.Equal(expected, s.plugin.Call(args)) +} + +func (s *PluginTestSuite) TestCallInvalidFormat() { + args := plugininterface.Args{ + Args: cty.ObjectVal(map[string]cty.Value{ + "text": cty.StringVal("Hello World!"), + "format_as": cty.StringVal("unknown"), + "absolute_title_size": cty.NullVal(cty.Number), + "code_language": cty.NullVal(cty.String), + }), + Context: nil, + } + expected := plugininterface.Result{ + Diags: hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Failed to render text", + Detail: "format_as must be one of text, title, code, blockquote", + }}, + } + s.Equal(expected, s.plugin.Call(args)) +} + +func (s *PluginTestSuite) TestCallInvalidTemplate() { + args := plugininterface.Args{ + Args: cty.ObjectVal(map[string]cty.Value{ + "text": cty.StringVal("Hello {{.name}!"), + "format_as": cty.NullVal(cty.String), + "absolute_title_size": cty.NullVal(cty.Number), + "code_language": cty.NullVal(cty.String), + }), + Context: map[string]any{ + "name": "World", + }, + } + expected := plugininterface.Result{ + Diags: hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: "Failed to render text", + Detail: "failed to parse text template: template: text:1: bad character U+007D '}'", + }}, + } + s.Equal(expected, s.plugin.Call(args)) +} + +func (s *PluginTestSuite) TestCallCodeDefault() { + args := plugininterface.Args{ + Args: cty.ObjectVal(map[string]cty.Value{ + "text": cty.StringVal(`Hello {{.name}}!`), + "format_as": cty.StringVal("code"), + "absolute_title_size": cty.NullVal(cty.Number), + "code_language": cty.NullVal(cty.String), + }), + Context: map[string]any{ + "name": "World", + }, + } + expected := plugininterface.Result{ + Result: "```\nHello World!\n```", + } + s.Equal(expected, s.plugin.Call(args)) +} + +func (s *PluginTestSuite) TestCallCodeNoLanguage() { + args := plugininterface.Args{ + Args: cty.ObjectVal(map[string]cty.Value{ + "text": cty.StringVal(`{"hello": "{{.name}}"}`), + "format_as": cty.StringVal("code"), + "absolute_title_size": cty.NullVal(cty.Number), + "code_language": cty.StringVal("json"), + }), + Context: map[string]any{ + "name": "world", + }, + } + expected := plugininterface.Result{ + Result: "```json\n{\"hello\": \"world\"}\n```", + } + s.Equal(expected, s.plugin.Call(args)) +} + +func (s *PluginTestSuite) TestCallBlockquote() { + args := plugininterface.Args{ + Args: cty.ObjectVal(map[string]cty.Value{ + "text": cty.StringVal(`Hello {{.name}}!`), + "format_as": cty.StringVal("blockquote"), + "absolute_title_size": cty.NullVal(cty.Number), + "code_language": cty.NullVal(cty.String), + }), + Context: map[string]any{ + "name": "World", + }, + } + expected := plugininterface.Result{ + Result: "> Hello World!", + } + s.Equal(expected, s.plugin.Call(args)) +} + +func (s *PluginTestSuite) TestCallBlockquoteMultiline() { + args := plugininterface.Args{ + Args: cty.ObjectVal(map[string]cty.Value{ + "text": cty.StringVal("Hello\n{{.name}}\nfor you!"), + "format_as": cty.StringVal("blockquote"), + "absolute_title_size": cty.NullVal(cty.Number), + "code_language": cty.NullVal(cty.String), + }), + Context: map[string]any{ + "name": "World", + }, + } + expected := plugininterface.Result{ + Result: "> Hello\n> World\n> for you!", + } + s.Equal(expected, s.plugin.Call(args)) +} + +func (s *PluginTestSuite) TestCallBlockquoteMultilineDoubleNewline() { + args := plugininterface.Args{ + Args: cty.ObjectVal(map[string]cty.Value{ + "text": cty.StringVal("Hello\n{{.name}}\n\nfor you!"), + "format_as": cty.StringVal("blockquote"), + "absolute_title_size": cty.NullVal(cty.Number), + "code_language": cty.NullVal(cty.String), + }), + Context: map[string]any{ + "name": "World", + }, + } + expected := plugininterface.Result{ + Result: "> Hello\n> World\n> \n> for you!", + } + s.Equal(expected, s.plugin.Call(args)) +}