diff --git a/atmos.yaml b/atmos.yaml index e9793b62f..5d3492eeb 100644 --- a/atmos.yaml +++ b/atmos.yaml @@ -316,8 +316,37 @@ settings: # deep-merged with all items in the source list. list_merge_strategy: replace + # Terminal settings for displaying content + terminal: + max_width: 120 # Maximum width for terminal output + pager: true # Use pager for long output + timestamps: false # Show timestamps in logs + colors: true # Enable colored output + unicode: true # Use unicode characters + + # Markdown element styling + markdown: + document: + color: "${colors.text}" + heading: + color: "${colors.primary}" + bold: true + code_block: + color: "${colors.secondary}" + margin: 1 + link: + color: "${colors.primary}" + underline: true + strong: + color: "${colors.secondary}" + bold: true + emph: + color: "${colors.muted}" + italic: true + version: check: enabled: true timeout: 1000 # ms frequency: 1h + diff --git a/cmd/docs.go b/cmd/docs.go index 650a74d25..4cc1fc542 100644 --- a/cmd/docs.go +++ b/cmd/docs.go @@ -41,7 +41,11 @@ var docsCmd = &cobra.Command{ // Detect terminal width if not specified in `atmos.yaml` // The default screen width is 120 characters, but uses maxWidth if set and greater than zero - maxWidth := atmosConfig.Settings.Docs.MaxWidth + maxWidth := atmosConfig.Settings.Terminal.MaxWidth + if maxWidth == 0 && atmosConfig.Settings.Docs.MaxWidth > 0 { + maxWidth = atmosConfig.Settings.Docs.MaxWidth + u.LogWarning(atmosConfig, "'settings.docs.max-width' is deprecated and will be removed in a future version. Please use 'settings.terminal.max_width' instead") + } defaultWidth := 120 screenWidth := defaultWidth @@ -97,7 +101,13 @@ var docsCmd = &cobra.Command{ u.LogErrorAndExit(schema.AtmosConfiguration{}, err) } - if err := u.DisplayDocs(componentDocs, atmosConfig.Settings.Docs.Pagination); err != nil { + usePager := atmosConfig.Settings.Terminal.Pager + if !usePager && atmosConfig.Settings.Docs.Pagination { + usePager = atmosConfig.Settings.Docs.Pagination + u.LogWarning(atmosConfig, "'settings.docs.pagination' is deprecated and will be removed in a future version. Please use 'settings.terminal.pager' instead") + } + + if err := u.DisplayDocs(componentDocs, usePager); err != nil { u.LogErrorAndExit(schema.AtmosConfiguration{}, fmt.Errorf("failed to display documentation: %w", err)) } diff --git a/cmd/markdown/workflow.md b/cmd/markdown/workflow.md new file mode 100644 index 000000000..9fd74685a --- /dev/null +++ b/cmd/markdown/workflow.md @@ -0,0 +1,20 @@ +Examples: + + – Use interactive UI + + $ atmos workflow + + – Execute a workflow + + $ atmos workflow --file + + – Execute with stack override + + $ atmos workflow --file --stack + + – Resume from specific step + + $ atmos workflow --file --from-step + +For more information, refer to the **docs**: +https://atmos.tools/cli/commands/workflow/ diff --git a/cmd/workflow.go b/cmd/workflow.go index cc657a6c9..1d9167967 100644 --- a/cmd/workflow.go +++ b/cmd/workflow.go @@ -1,40 +1,200 @@ package cmd import ( + _ "embed" + "fmt" + "os" + "strings" + "github.com/spf13/cobra" e "github.com/cloudposse/atmos/internal/exec" + termwriter "github.com/cloudposse/atmos/internal/tui/templates/term" + cfg "github.com/cloudposse/atmos/pkg/config" "github.com/cloudposse/atmos/pkg/schema" + "github.com/cloudposse/atmos/pkg/ui/markdown" u "github.com/cloudposse/atmos/pkg/utils" ) +//go:embed markdown/workflow.md +var workflowMarkdown string + +// ErrorMessage represents a structured error message +type ErrorMessage struct { + Title string + Details string + Suggestion string +} + +// renderError renders an error message using the markdown renderer +func renderError(msg ErrorMessage) error { + atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false) + if err != nil { + return fmt.Errorf("failed to initialize atmos config: %w", err) + } + + termWriter := termwriter.NewResponsiveWriter(os.Stdout) + screenWidth := termWriter.(*termwriter.TerminalWriter).GetWidth() + + if atmosConfig.Settings.Docs.MaxWidth > 0 { + screenWidth = uint(min(atmosConfig.Settings.Docs.MaxWidth, int(screenWidth))) + } + + renderer, err := markdown.NewRenderer( + markdown.WithWidth(screenWidth), + ) + if err != nil { + return fmt.Errorf("failed to create markdown renderer: %w", err) + } + + rendered, err := renderer.RenderError(msg.Title, msg.Details, msg.Suggestion) + if err != nil { + return fmt.Errorf("failed to render error message: %w", err) + } + + fmt.Print(rendered) + return nil +} + +// getMarkdownSection returns a section from the markdown file +func getMarkdownSection(title string) (details, suggestion string) { + sections := markdown.ParseMarkdownSections(workflowMarkdown) + if section, ok := sections[title]; ok { + parts := markdown.SplitMarkdownContent(section) + if len(parts) >= 2 { + return parts[0], parts[1] + } + return section, "" + } + return "", "" +} + // workflowCmd executes a workflow var workflowCmd = &cobra.Command{ Use: "workflow", Short: "Execute a workflow", - Long: `This command executes a workflow: atmos workflow -f `, + Long: `This command executes a workflow: atmos workflow --file `, Example: "atmos workflow\n" + - "atmos workflow -f \n" + - "atmos workflow -f -s \n" + - "atmos workflow -f --from-step \n\n" + + "atmos workflow --file \n" + + "atmos workflow --file --stack \n" + + "atmos workflow --file --from-step \n\n" + "To resume the workflow from this step, run:\n" + - "atmos workflow deploy-infra -f workflow1 --from-step deploy-vpc\n\n" + + "atmos workflow deploy-infra --file workflow1 --from-step deploy-vpc\n\n" + "For more details refer to https://atmos.tools/cli/commands/workflow/", FParseErrWhitelist: struct{ UnknownFlags bool }{UnknownFlags: false}, Run: func(cmd *cobra.Command, args []string) { + // If no arguments are provided, start the workflow UI + if len(args) == 0 { + err := e.ExecuteWorkflowCmd(cmd, args) + if err != nil { + u.LogErrorAndExit(schema.AtmosConfiguration{}, err) + } + return + } + + if args[0] == "help" { + if err := cmd.Help(); err != nil { + u.LogErrorAndExit(schema.AtmosConfiguration{}, err) + } + return + } + + // Get the --file flag value + workflowFile, _ := cmd.Flags().GetString("file") + + // If no file is provided, show invalid command error with usage information + if workflowFile == "" { + // Get atmos configuration + atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false) + if err != nil { + u.LogErrorAndExit(schema.AtmosConfiguration{}, fmt.Errorf("failed to initialize atmos config: %w", err)) + } + + // Create a terminal writer to get the optimal width + termWriter := termwriter.NewResponsiveWriter(os.Stdout) + screenWidth := termWriter.(*termwriter.TerminalWriter).GetWidth() + + if atmosConfig.Settings.Docs.MaxWidth > 0 { + screenWidth = uint(min(atmosConfig.Settings.Docs.MaxWidth, int(screenWidth))) + } + + renderer, err := markdown.NewRenderer( + markdown.WithWidth(screenWidth), + ) + if err != nil { + u.LogErrorAndExit(schema.AtmosConfiguration{}, fmt.Errorf("failed to create markdown renderer: %w", err)) + } + + // Generate the error message dynamically using H1 styling + errorMsg := fmt.Sprintf("# Invalid Command\n\nThe command `atmos workflow %s` is not valid.\n\n", args[0]) + content := errorMsg + workflowMarkdown + rendered, err := renderer.Render(content) + if err != nil { + u.LogErrorAndExit(schema.AtmosConfiguration{}, fmt.Errorf("failed to render markdown: %w", err)) + } + + // Remove duplicate URLs and format output + lines := strings.Split(rendered, "\n") + var result []string + seenURL := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.Contains(trimmed, "https://") { + if !seenURL { + seenURL = true + result = append(result, line) + } + } else if strings.HasPrefix(trimmed, "$") { + result = append(result, " "+strings.TrimSpace(line)) + } else if trimmed != "" { + result = append(result, line) + } + } + + fmt.Print("\n" + strings.Join(result, "\n") + "\n\n") + os.Exit(1) + } + + // Execute the workflow command err := e.ExecuteWorkflowCmd(cmd, args) if err != nil { - u.LogErrorAndExit(schema.AtmosConfiguration{}, err) + // Format common error messages + if strings.Contains(err.Error(), "does not exist") { + details, suggestion := getMarkdownSection("Workflow File Not Found") + err := renderError(ErrorMessage{ + Title: "Workflow File Not Found", + Details: details, + Suggestion: suggestion, + }) + if err != nil { + u.LogErrorAndExit(schema.AtmosConfiguration{}, err) + } + } else if strings.Contains(err.Error(), "does not have the") { + details, suggestion := getMarkdownSection("Invalid Workflow") + err := renderError(ErrorMessage{ + Title: "Invalid Workflow", + Details: details, + Suggestion: suggestion, + }) + if err != nil { + u.LogErrorAndExit(schema.AtmosConfiguration{}, err) + } + } else { + // For other errors, use the standard error handler + u.LogErrorAndExit(schema.AtmosConfiguration{}, err) + } + return } }, } func init() { workflowCmd.DisableFlagParsing = false - workflowCmd.PersistentFlags().StringP("file", "f", "", "atmos workflow -f ") - workflowCmd.PersistentFlags().Bool("dry-run", false, "atmos workflow -f --dry-run") - workflowCmd.PersistentFlags().StringP("stack", "s", "", "atmos workflow -f -s ") - workflowCmd.PersistentFlags().String("from-step", "", "atmos workflow -f --from-step ") + workflowCmd.PersistentFlags().StringP("file", "f", "", "atmos workflow --file ") + workflowCmd.PersistentFlags().Bool("dry-run", false, "atmos workflow --file --dry-run") + workflowCmd.PersistentFlags().StringP("stack", "s", "", "atmos workflow --file --stack ") + workflowCmd.PersistentFlags().String("from-step", "", "atmos workflow --file --from-step ") RootCmd.AddCommand(workflowCmd) } diff --git a/go.mod b/go.mod index c7cc392e5..ba46402a9 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/charmbracelet/log v0.4.0 github.com/elewis787/boa v0.1.2 github.com/fatih/color v1.18.0 - github.com/go-git/go-git/v5 v5.13.0 + github.com/go-git/go-git/v5 v5.13.1 github.com/gofrs/flock v0.12.1 github.com/google/go-containerregistry v0.20.2 github.com/google/go-github/v59 v59.0.0 @@ -39,6 +39,7 @@ require ( github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-wordwrap v1.0.1 github.com/mitchellh/mapstructure v1.5.0 + github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a github.com/open-policy-agent/opa v1.0.0 github.com/otiai10/copy v1.14.0 github.com/pkg/errors v0.9.1 @@ -113,7 +114,7 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/containerd/stargz-snapshotter/estargz v0.14.3 // indirect - github.com/cyphar/filepath-securejoin v0.2.5 // indirect + github.com/cyphar/filepath-securejoin v0.3.6 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.0 // indirect github.com/docker/cli v27.1.1+incompatible // indirect @@ -127,7 +128,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.6.0 // indirect + github.com/go-git/go-billy/v5 v5.6.1 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.2 // indirect @@ -194,7 +195,6 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/run v1.1.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect diff --git a/go.sum b/go.sum index 2e9c307f2..3f6cf9c6c 100644 --- a/go.sum +++ b/go.sum @@ -480,8 +480,8 @@ github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7Do github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo= -github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM= +github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -513,8 +513,8 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad h1:Qk76DOWdOp+GlyDKBAG3Klr9cn7N+LcYc82AZ2S7+cA= github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad/go.mod h1:mPKfmRa823oBIgl2r20LeMSpTAteW5j7FLkc0vjmzyQ= github.com/dvyukov/go-fuzz v0.0.0-20210103155950-6a8e9d1f2415/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= -github.com/elazarl/goproxy v1.2.1 h1:njjgvO6cRG9rIqN2ebkqy6cQz2Njkx7Fsfv/zIZqgug= -github.com/elazarl/goproxy v1.2.1/go.mod h1:YfEbZtqP4AetfO6d40vWchF3znWX7C7Vd6ZMfdL8z64= +github.com/elazarl/goproxy v1.2.3 h1:xwIyKHbaP5yfT6O9KIeYJR5549MXRQkoQMRXGztz8YQ= +github.com/elazarl/goproxy v1.2.3/go.mod h1:YfEbZtqP4AetfO6d40vWchF3znWX7C7Vd6ZMfdL8z64= github.com/elewis787/boa v0.1.2 h1:xNKWJ9X2MWbLSLLOA31N4l1Jdec9FZSkbTvXy3C8rw4= github.com/elewis787/boa v0.1.2/go.mod h1:EFDKuz/bYgQAKJQBnfHmB9i+bBzsaZJyyoSmOz6eBZI= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= @@ -562,12 +562,12 @@ github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1 github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8= -github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= +github.com/go-git/go-billy/v5 v5.6.1 h1:u+dcrgaguSSkbjzHwelEjc0Yj300NUevrrPphk/SoRA= +github.com/go-git/go-billy/v5 v5.6.1/go.mod h1:0AsLr1z2+Uksi4NlElmMblP5rPcDZNRCD8ujZCRR2BE= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.13.0 h1:vLn5wlGIh/X78El6r3Jr+30W16Blk0CTcxTYcYPWi5E= -github.com/go-git/go-git/v5 v5.13.0/go.mod h1:Wjo7/JyVKtQgUNdXYXIepzWfJQkUEIGvkvVkiXRR/zw= +github.com/go-git/go-git/v5 v5.13.1 h1:DAQ9APonnlvSWpvolXWIuV6Q6zXy2wHbN4cVlNR5Q+M= +github.com/go-git/go-git/v5 v5.13.1/go.mod h1:qryJB4cSBoq3FRoBRf5A77joojuBcmPJ0qu3XXXVixc= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= diff --git a/internal/tui/components/code_view/main.go b/internal/tui/components/code_view/main.go index 0719eeda4..ebe4d4664 100644 --- a/internal/tui/components/code_view/main.go +++ b/internal/tui/components/code_view/main.go @@ -12,6 +12,7 @@ type Model struct { Viewport viewport.Model HighlightedContent string SyntaxTheme string + IsMarkdown bool } // New creates a new instance of the model @@ -25,6 +26,7 @@ func New(syntaxTheme string) Model { return Model{ Viewport: viewPort, SyntaxTheme: syntaxTheme, + IsMarkdown: false, } } @@ -35,8 +37,26 @@ func (m *Model) Init() tea.Cmd { // SetContent sets content func (m *Model) SetContent(content string, language string) { - highlighted, _ := u.HighlightCode(content, language, m.SyntaxTheme) - m.HighlightedContent = highlighted + var rendered string + var err error + + if language == "markdown" || language == "md" { + m.IsMarkdown = true + rendered, err = u.RenderMarkdown(content, "") + if err != nil { + // Fallback to plain text if markdown rendering fails + rendered = content + } + } else { + m.IsMarkdown = false + rendered, err = u.HighlightCode(content, language, m.SyntaxTheme) + if err != nil { + // Fallback to plain text if syntax highlighting fails + rendered = content + } + } + + m.HighlightedContent = rendered m.Viewport.ViewUp() m.Viewport.MouseWheelEnabled = true @@ -44,7 +64,7 @@ func (m *Model) SetContent(content string, language string) { m.Viewport.SetContent(lipgloss.NewStyle(). Width(m.Viewport.Width). Height(m.Viewport.Height). - Render(highlighted)) + Render(rendered)) } // SetSyntaxTheme sets the syntax theme diff --git a/internal/tui/utils/utils.go b/internal/tui/utils/utils.go index 42bedabb7..e737ba3cf 100644 --- a/internal/tui/utils/utils.go +++ b/internal/tui/utils/utils.go @@ -2,11 +2,15 @@ package utils import ( "bytes" + "fmt" "os" "github.com/alecthomas/chroma/quick" "github.com/arsham/figurine/figurine" + "github.com/charmbracelet/glamour" + mdstyle "github.com/cloudposse/atmos/pkg/ui/markdown" "github.com/jwalton/go-supportscolor" + xterm "golang.org/x/term" ) // HighlightCode returns a syntax highlighted code for the specified language @@ -27,3 +31,43 @@ func PrintStyledText(text string) error { } return nil } + +// RenderMarkdown renders markdown text with terminal styling +func RenderMarkdown(markdownText string, style string) (string, error) { + if markdownText == "" { + return "", fmt.Errorf("empty markdown input") + } + + // Get the custom style from atmos config + customStyle, err := mdstyle.GetDefaultStyle() + if err != nil { + return "", fmt.Errorf("failed to get markdown style: %w", err) + } + + // Get terminal width safely + var screenWidth int + if w, _, err := xterm.GetSize(int(os.Stdout.Fd())); err == nil { + screenWidth = w + } else { + // Fallback to a reasonable default if we can't get the terminal width + screenWidth = 80 + } + + // Create a new renderer with the specified style + r, err := glamour.NewTermRenderer( + // Use our custom style if available + glamour.WithStylesFromJSONBytes(customStyle), + glamour.WithWordWrap(screenWidth), + ) + if err != nil { + return "", fmt.Errorf("failed to create markdown renderer: %w", err) + } + defer r.Close() + + out, err := r.Render(markdownText) + if err != nil { + return "", fmt.Errorf("failed to render markdown: %w", err) + } + + return out, nil +} diff --git a/internal/tui/workflow/model.go b/internal/tui/workflow/model.go index d7f2cb211..e999b270e 100644 --- a/internal/tui/workflow/model.go +++ b/internal/tui/workflow/model.go @@ -17,15 +17,15 @@ import ( type App struct { help help.Model - loaded bool columnViews []columnView - quit bool - workflows map[string]schema.WorkflowManifest + columnPointer int selectedWorkflowFile string selectedWorkflow string selectedWorkflowStep string - columnPointer int + workflows map[string]schema.WorkflowManifest workflowStepsViewShowWorkflow bool + quit bool + loaded bool } func NewApp(workflows map[string]schema.WorkflowManifest) *App { @@ -47,136 +47,6 @@ func NewApp(workflows map[string]schema.WorkflowManifest) *App { return app } -func (app *App) Init() tea.Cmd { - return nil -} - -func (app *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // Process messages relevant to the parent view - switch message := msg.(type) { - case tea.WindowSizeMsg: - app.loaded = false - var cmd tea.Cmd - var cmds []tea.Cmd - app.help.Width = message.Width - for i := 0; i < len(app.columnViews); i++ { - var res tea.Model - res, cmd = app.columnViews[i].Update(message) - app.columnViews[i] = *res.(*columnView) - cmds = append(cmds, cmd) - } - app.loaded = true - return app, tea.Batch(cmds...) - - case tea.MouseMsg: - if message.Button == tea.MouseButtonWheelUp { - app.columnViews[app.columnPointer].CursorUp() - app.updateWorkflowFilesAndWorkflowsViews() - return app, nil - } - if message.Button == tea.MouseButtonWheelDown { - app.columnViews[app.columnPointer].CursorDown() - app.updateWorkflowFilesAndWorkflowsViews() - return app, nil - } - if message.Button == tea.MouseButtonLeft { - for i := 0; i < len(app.columnViews); i++ { - zoneInfo := mouseZone.Get(app.columnViews[i].id) - if zoneInfo.InBounds(message) { - app.columnViews[app.columnPointer].Blur() - app.columnPointer = i - app.columnViews[app.columnPointer].Focus() - break - } - } - } - - case tea.KeyMsg: - switch { - case key.Matches(message, keys.CtrlC): - app.quit = true - return app, tea.Quit - case key.Matches(message, keys.Escape): - if app.columnViews[app.columnPointer].viewType == listViewType || app.columnViews[app.columnPointer].viewType == listViewType2 { - res, cmd := app.columnViews[app.columnPointer].Update(msg) - app.columnViews[app.columnPointer] = *res.(*columnView) - if cmd == nil { - return app, nil - } else { - app.quit = true - return app, tea.Quit - } - } - app.quit = true - return app, tea.Quit - case key.Matches(message, keys.Execute): - app.execute() - return app, tea.Quit - case key.Matches(message, keys.Up): - app.columnViews[app.columnPointer].CursorUp() - app.updateWorkflowFilesAndWorkflowsViews() - return app, nil - case key.Matches(message, keys.Down): - app.columnViews[app.columnPointer].CursorDown() - app.updateWorkflowFilesAndWorkflowsViews() - return app, nil - case key.Matches(message, keys.Left): - app.columnViews[app.columnPointer].Blur() - app.columnPointer = app.getPrevViewPointer() - app.columnViews[app.columnPointer].Focus() - return app, nil - case key.Matches(message, keys.Right): - app.columnViews[app.columnPointer].Blur() - app.columnPointer = app.getNextViewPointer() - app.columnViews[app.columnPointer].Focus() - return app, nil - case key.Matches(message, keys.FlipWorkflowStepsView): - app.flipWorkflowStepsView() - return app, nil - } - } - - // Send all other messages to the selected child view - res, cmd := app.columnViews[app.columnPointer].Update(msg) - app.columnViews[app.columnPointer] = *res.(*columnView) - return app, cmd -} - -func (app *App) View() string { - if app.quit { - return "" - } - - if !app.loaded { - return "loading..." - } - - layout := lipgloss.JoinHorizontal( - lipgloss.Left, - app.columnViews[0].View(), - app.columnViews[1].View(), - app.columnViews[2].View(), - ) - - return mouseZone.Scan(lipgloss.JoinVertical(lipgloss.Left, layout, app.help.View(keys))) -} - -func (app *App) GetSelectedWorkflowFile() string { - return app.selectedWorkflowFile -} - -func (app *App) GetSelectedWorkflow() string { - return app.selectedWorkflow -} - -func (app *App) GetSelectedWorkflowStep() string { - return app.selectedWorkflowStep -} - -func (app *App) ExitStatusQuit() bool { - return app.quit -} - func (app *App) initViews(workflows map[string]schema.WorkflowManifest) { app.columnViews = []columnView{ newColumn(0, listViewType), @@ -216,7 +86,12 @@ func (app *App) initViews(workflows map[string]schema.WorkflowManifest) { selectedWorkflowDefinition := workflows[selectedWorkflowFileName].Workflows[selectedWorkflowName] stepItems = lo.Map(selectedWorkflowDefinition.Steps, func(s schema.WorkflowStep, _ int) list.Item { + name := s.Name + if name == "" { + name = s.Command + } return listItem{ + name: name, item: s.Name, } }) @@ -246,20 +121,6 @@ func (app *App) initViews(workflows map[string]schema.WorkflowManifest) { app.columnViews[2].SetContent(selectedWorkflowContent, "yaml") } -func (app *App) getNextViewPointer() int { - if app.columnPointer == 2 { - return 0 - } - return app.columnPointer + 1 -} - -func (app *App) getPrevViewPointer() int { - if app.columnPointer == 0 { - return 2 - } - return app.columnPointer - 1 -} - func (app *App) updateWorkflowFilesAndWorkflowsViews() { if app.columnPointer == 0 { selectedWorkflowFile := app.columnViews[0].list.SelectedItem() @@ -288,7 +149,12 @@ func (app *App) updateWorkflowFilesAndWorkflowsViews() { selectedWorkflowDefinition := app.workflows[selectedWorkflowFileName].Workflows[selectedWorkflowName] stepItems := lo.Map(selectedWorkflowDefinition.Steps, func(s schema.WorkflowStep, _ int) list.Item { + name := s.Name + if name == "" { + name = s.Command + } return listItem{ + name: name, item: s.Name, } }) @@ -315,7 +181,12 @@ func (app *App) updateWorkflowFilesAndWorkflowsViews() { selectedWorkflowDefinition := app.workflows[selectedWorkflowFileName].Workflows[selectedWorkflowName] stepItems := lo.Map(selectedWorkflowDefinition.Steps, func(s schema.WorkflowStep, _ int) list.Item { + name := s.Name + if name == "" { + name = s.Command + } return listItem{ + name: name, item: s.Name, } }) @@ -361,3 +232,147 @@ func (app *App) execute() { app.selectedWorkflowStep = "" } } + +func (app *App) Init() tea.Cmd { + return nil +} + +func (app *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // Process messages relevant to the parent view + switch message := msg.(type) { + case tea.WindowSizeMsg: + app.loaded = false + var cmd tea.Cmd + var cmds []tea.Cmd + app.help.Width = message.Width + for i := 0; i < len(app.columnViews); i++ { + var res tea.Model + res, cmd = app.columnViews[i].Update(message) + app.columnViews[i] = *res.(*columnView) + cmds = append(cmds, cmd) + } + app.loaded = true + return app, tea.Batch(cmds...) + + case tea.MouseMsg: + if message.Button == tea.MouseButtonWheelUp { + app.columnViews[app.columnPointer].CursorUp() + app.updateWorkflowFilesAndWorkflowsViews() + return app, nil + } + if message.Button == tea.MouseButtonWheelDown { + app.columnViews[app.columnPointer].CursorDown() + app.updateWorkflowFilesAndWorkflowsViews() + return app, nil + } + if message.Button == tea.MouseButtonLeft { + for i := 0; i < len(app.columnViews); i++ { + zoneInfo := mouseZone.Get(app.columnViews[i].id) + if zoneInfo.InBounds(message) { + app.columnViews[app.columnPointer].Blur() + app.columnPointer = i + app.columnViews[app.columnPointer].Focus() + break + } + } + } + + case tea.KeyMsg: + switch { + case key.Matches(message, keys.CtrlC): + app.quit = true + return app, tea.Quit + case key.Matches(message, keys.Escape): + if app.columnViews[app.columnPointer].viewType == listViewType || app.columnViews[app.columnPointer].viewType == listViewType2 { + res, cmd := app.columnViews[app.columnPointer].Update(msg) + app.columnViews[app.columnPointer] = *res.(*columnView) + if cmd == nil { + return app, nil + } else { + app.quit = true + return app, tea.Quit + } + } + app.quit = true + return app, tea.Quit + case key.Matches(message, keys.Execute): + app.execute() + return app, tea.Quit + case key.Matches(message, keys.Up): + app.columnViews[app.columnPointer].CursorUp() + app.updateWorkflowFilesAndWorkflowsViews() + return app, nil + case key.Matches(message, keys.Down): + app.columnViews[app.columnPointer].CursorDown() + app.updateWorkflowFilesAndWorkflowsViews() + return app, nil + case key.Matches(message, keys.Left): + app.columnViews[app.columnPointer].Blur() + app.columnPointer = app.getPrevViewPointer() + app.columnViews[app.columnPointer].Focus() + return app, nil + case key.Matches(message, keys.Right): + app.columnViews[app.columnPointer].Blur() + app.columnPointer = app.getNextViewPointer() + app.columnViews[app.columnPointer].Focus() + return app, nil + case key.Matches(message, keys.FlipWorkflowStepsView): + app.flipWorkflowStepsView() + return app, nil + } + } + + // Send all other messages to the selected child view + res, cmd := app.columnViews[app.columnPointer].Update(msg) + app.columnViews[app.columnPointer] = *res.(*columnView) + return app, cmd +} + +func (app *App) View() string { + if app.quit { + return "" + } + + if !app.loaded { + return "loading..." + } + + layout := lipgloss.JoinHorizontal( + lipgloss.Left, + app.columnViews[0].View(), + app.columnViews[1].View(), + app.columnViews[2].View(), + ) + + return mouseZone.Scan(lipgloss.JoinVertical(lipgloss.Left, layout, app.help.View(keys))) +} + +func (app *App) GetSelectedWorkflowFile() string { + return app.selectedWorkflowFile +} + +func (app *App) GetSelectedWorkflow() string { + return app.selectedWorkflow +} + +func (app *App) GetSelectedWorkflowStep() string { + return app.selectedWorkflowStep +} + +func (app *App) ExitStatusQuit() bool { + return app.quit +} + +func (app *App) getNextViewPointer() int { + if app.columnPointer == 2 { + return 0 + } + return app.columnPointer + 1 +} + +func (app *App) getPrevViewPointer() int { + if app.columnPointer == 0 { + return 2 + } + return app.columnPointer - 1 +} diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index 668a39b90..af7e3c5d2 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -37,9 +37,19 @@ type AtmosConfiguration struct { Stores store.StoreRegistry `yaml:"stores_registry,omitempty" json:"stores_registry,omitempty" mapstructure:"stores_registry"` } +type Terminal struct { + MaxWidth int `yaml:"max_width" json:"max_width" mapstructure:"max_width"` + Pager bool `yaml:"pager" json:"pager" mapstructure:"pager"` + Timestamps bool `yaml:"timestamps" json:"timestamps" mapstructure:"timestamps"` + Colors bool `yaml:"colors" json:"colors" mapstructure:"colors"` + Unicode bool `yaml:"unicode" json:"unicode" mapstructure:"unicode"` +} + type AtmosSettings struct { - ListMergeStrategy string `yaml:"list_merge_strategy" json:"list_merge_strategy" mapstructure:"list_merge_strategy"` - Docs Docs `yaml:"docs,omitempty" json:"docs,omitempty" mapstructure:"docs"` + ListMergeStrategy string `yaml:"list_merge_strategy" json:"list_merge_strategy" mapstructure:"list_merge_strategy"` + Terminal Terminal `yaml:"terminal,omitempty" json:"terminal,omitempty" mapstructure:"terminal"` + Docs Docs `yaml:"docs,omitempty" json:"docs,omitempty" mapstructure:"docs"` + Markdown MarkdownSettings `yaml:"markdown,omitempty" json:"markdown,omitempty" mapstructure:"markdown"` } type Docs struct { @@ -590,3 +600,57 @@ type Vendor struct { // If a directory is specified, all .yaml files in the directory will be processed in lexicographical order BasePath string `yaml:"base_path" json:"base_path" mapstructure:"base_path"` } + +type MarkdownSettings struct { + Document MarkdownStyle `yaml:"document,omitempty" json:"document,omitempty" mapstructure:"document"` + BlockQuote MarkdownStyle `yaml:"block_quote,omitempty" json:"block_quote,omitempty" mapstructure:"block_quote"` + Paragraph MarkdownStyle `yaml:"paragraph,omitempty" json:"paragraph,omitempty" mapstructure:"paragraph"` + List MarkdownStyle `yaml:"list,omitempty" json:"list,omitempty" mapstructure:"list"` + ListItem MarkdownStyle `yaml:"list_item,omitempty" json:"list_item,omitempty" mapstructure:"list_item"` + Heading MarkdownStyle `yaml:"heading,omitempty" json:"heading,omitempty" mapstructure:"heading"` + H1 MarkdownStyle `yaml:"h1,omitempty" json:"h1,omitempty" mapstructure:"h1"` + H2 MarkdownStyle `yaml:"h2,omitempty" json:"h2,omitempty" mapstructure:"h2"` + H3 MarkdownStyle `yaml:"h3,omitempty" json:"h3,omitempty" mapstructure:"h3"` + H4 MarkdownStyle `yaml:"h4,omitempty" json:"h4,omitempty" mapstructure:"h4"` + H5 MarkdownStyle `yaml:"h5,omitempty" json:"h5,omitempty" mapstructure:"h5"` + H6 MarkdownStyle `yaml:"h6,omitempty" json:"h6,omitempty" mapstructure:"h6"` + Text MarkdownStyle `yaml:"text,omitempty" json:"text,omitempty" mapstructure:"text"` + Strong MarkdownStyle `yaml:"strong,omitempty" json:"strong,omitempty" mapstructure:"strong"` + Emph MarkdownStyle `yaml:"emph,omitempty" json:"emph,omitempty" mapstructure:"emph"` + Hr MarkdownStyle `yaml:"hr,omitempty" json:"hr,omitempty" mapstructure:"hr"` + Item MarkdownStyle `yaml:"item,omitempty" json:"item,omitempty" mapstructure:"item"` + Enumeration MarkdownStyle `yaml:"enumeration,omitempty" json:"enumeration,omitempty" mapstructure:"enumeration"` + Code MarkdownStyle `yaml:"code,omitempty" json:"code,omitempty" mapstructure:"code"` + CodeBlock MarkdownStyle `yaml:"code_block,omitempty" json:"code_block,omitempty" mapstructure:"code_block"` + Table MarkdownStyle `yaml:"table,omitempty" json:"table,omitempty" mapstructure:"table"` + DefinitionList MarkdownStyle `yaml:"definition_list,omitempty" json:"definition_list,omitempty" mapstructure:"definition_list"` + DefinitionTerm MarkdownStyle `yaml:"definition_term,omitempty" json:"definition_term,omitempty" mapstructure:"definition_term"` + DefinitionDescription MarkdownStyle `yaml:"definition_description,omitempty" json:"definition_description,omitempty" mapstructure:"definition_description"` + HtmlBlock MarkdownStyle `yaml:"html_block,omitempty" json:"html_block,omitempty" mapstructure:"html_block"` + HtmlSpan MarkdownStyle `yaml:"html_span,omitempty" json:"html_span,omitempty" mapstructure:"html_span"` + Link MarkdownStyle `yaml:"link,omitempty" json:"link,omitempty" mapstructure:"link"` + LinkText MarkdownStyle `yaml:"link_text,omitempty" json:"link_text,omitempty" mapstructure:"link_text"` +} + +type MarkdownStyle struct { + BlockPrefix string `yaml:"block_prefix,omitempty" json:"block_prefix,omitempty" mapstructure:"block_prefix"` + BlockSuffix string `yaml:"block_suffix,omitempty" json:"block_suffix,omitempty" mapstructure:"block_suffix"` + Color string `yaml:"color,omitempty" json:"color,omitempty" mapstructure:"color"` + BackgroundColor string `yaml:"background_color,omitempty" json:"background_color,omitempty" mapstructure:"background_color"` + Bold bool `yaml:"bold,omitempty" json:"bold,omitempty" mapstructure:"bold"` + Italic bool `yaml:"italic,omitempty" json:"italic,omitempty" mapstructure:"italic"` + Underline bool `yaml:"underline,omitempty" json:"underline,omitempty" mapstructure:"underline"` + Margin int `yaml:"margin,omitempty" json:"margin,omitempty" mapstructure:"margin"` + Padding int `yaml:"padding,omitempty" json:"padding,omitempty" mapstructure:"padding"` + Indent int `yaml:"indent,omitempty" json:"indent,omitempty" mapstructure:"indent"` + IndentToken string `yaml:"indent_token,omitempty" json:"indent_token,omitempty" mapstructure:"indent_token"` + LevelIndent int `yaml:"level_indent,omitempty" json:"level_indent,omitempty" mapstructure:"level_indent"` + Format string `yaml:"format,omitempty" json:"format,omitempty" mapstructure:"format"` + Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty" mapstructure:"prefix"` + StyleOverride bool `yaml:"style_override,omitempty" json:"style_override,omitempty" mapstructure:"style_override"` + Chroma map[string]ChromaStyle `yaml:"chroma,omitempty" json:"chroma,omitempty" mapstructure:"chroma"` +} + +type ChromaStyle struct { + Color string `yaml:"color,omitempty" json:"color,omitempty" mapstructure:"color"` +} diff --git a/pkg/ui/markdown/colors.go b/pkg/ui/markdown/colors.go new file mode 100644 index 000000000..b8d1bac95 --- /dev/null +++ b/pkg/ui/markdown/colors.go @@ -0,0 +1,50 @@ +package markdown + +import "github.com/charmbracelet/lipgloss" + +// Base colors used throughout the application +var ( + White = "#FFFFFF" + Purple = "#9B51E0" + Blue = "#00A3E0" + Gray = "#4A5568" + Green = "#48BB78" + Yellow = "#ECC94B" + Red = "#F56565" + LightBlue = "#4299E1" + BlueLight = "#63B3ED" + GrayLight = "#718096" + GrayMid = "#A0AEC0" + GrayDark = "#2D3748" + GrayBorder = "#CBD5E0" + OffWhite = "#F7FAFC" + DarkSlate = "#1A202C" + GreenLight = "#68D391" + YellowLight = "#F6E05E" + RedLight = "#FC8181" +) + +// Colors defines the color scheme for markdown rendering +var Colors = struct { + Primary lipgloss.AdaptiveColor + Secondary lipgloss.AdaptiveColor + Success lipgloss.AdaptiveColor + Warning lipgloss.AdaptiveColor + Error lipgloss.AdaptiveColor + Info lipgloss.AdaptiveColor + Subtle lipgloss.AdaptiveColor + HeaderBg lipgloss.AdaptiveColor + Border lipgloss.AdaptiveColor + Background lipgloss.AdaptiveColor +}{ + Primary: lipgloss.AdaptiveColor{Light: Blue, Dark: Blue}, + Secondary: lipgloss.AdaptiveColor{Light: Gray, Dark: GrayMid}, + Success: lipgloss.AdaptiveColor{Light: Green, Dark: GreenLight}, + Warning: lipgloss.AdaptiveColor{Light: Yellow, Dark: YellowLight}, + Error: lipgloss.AdaptiveColor{Light: Red, Dark: RedLight}, + Info: lipgloss.AdaptiveColor{Light: LightBlue, Dark: BlueLight}, + Subtle: lipgloss.AdaptiveColor{Light: GrayLight, Dark: GrayMid}, + HeaderBg: lipgloss.AdaptiveColor{Light: GrayDark, Dark: Gray}, + Border: lipgloss.AdaptiveColor{Light: GrayBorder, Dark: Gray}, + Background: lipgloss.AdaptiveColor{Light: OffWhite, Dark: DarkSlate}, +} diff --git a/pkg/ui/markdown/parser.go b/pkg/ui/markdown/parser.go new file mode 100644 index 000000000..9e9176cce --- /dev/null +++ b/pkg/ui/markdown/parser.go @@ -0,0 +1,55 @@ +package markdown + +import ( + "strings" +) + +// ParseMarkdownSections parses a markdown string and returns a map of section titles to their content +func ParseMarkdownSections(content string) map[string]string { + sections := make(map[string]string) + lines := strings.Split(content, "\n") + + var currentTitle string + var currentContent []string + + for _, line := range lines { + if strings.HasPrefix(line, "# ") { + // If we have a previous section, save it + if currentTitle != "" { + sections[currentTitle] = strings.TrimSpace(strings.Join(currentContent, "\n")) + } + // Start new section + currentTitle = strings.TrimPrefix(line, "# ") + currentContent = []string{} + } else if currentTitle != "" { + currentContent = append(currentContent, line) + } + } + + // Save the last section + if currentTitle != "" { + sections[currentTitle] = strings.TrimSpace(strings.Join(currentContent, "\n")) + } + + return sections +} + +// SplitMarkdownContent splits markdown content into details and suggestion parts +func SplitMarkdownContent(content string) []string { + parts := strings.Split(content, "\n\n") + var result []string + + // First non-empty line is details + for i, part := range parts { + if strings.TrimSpace(part) != "" { + result = append(result, strings.TrimSpace(part)) + if i < len(parts)-1 { + // Rest is suggestion + result = append(result, strings.TrimSpace(strings.Join(parts[i+1:], "\n\n"))) + } + break + } + } + + return result +} diff --git a/pkg/ui/markdown/renderer.go b/pkg/ui/markdown/renderer.go new file mode 100644 index 000000000..beac0ca59 --- /dev/null +++ b/pkg/ui/markdown/renderer.go @@ -0,0 +1,159 @@ +package markdown + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/glamour" + "github.com/muesli/termenv" +) + +// Renderer is a markdown renderer using Glamour +type Renderer struct { + renderer *glamour.TermRenderer + width uint + profile termenv.Profile +} + +// NewRenderer creates a new markdown renderer with the given options +func NewRenderer(opts ...Option) (*Renderer, error) { + r := &Renderer{ + width: 80, // default width + profile: termenv.ColorProfile(), // default color profile + } + + // Apply options + for _, opt := range opts { + opt(r) + } + + // Get default style + style, err := GetDefaultStyle() + if err != nil { + return nil, err + } + + // Initialize glamour renderer + renderer, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(int(r.width)), + glamour.WithStylesFromJSONBytes(style), + glamour.WithColorProfile(r.profile), + glamour.WithEmoji(), + ) + if err != nil { + return nil, err + } + + r.renderer = renderer + return r, nil +} + +// Render renders markdown content to ANSI styled text +func (r *Renderer) Render(content string) (string, error) { + return r.renderer.Render(content) +} + +// RenderWithStyle renders markdown content with a specific style +func (r *Renderer) RenderWithStyle(content string, style []byte) (string, error) { + renderer, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(int(r.width)), + glamour.WithStylesFromJSONBytes(style), + glamour.WithColorProfile(r.profile), + glamour.WithEmoji(), + ) + if err != nil { + return "", err + } + + return renderer.Render(content) +} + +// RenderWorkflow renders workflow documentation with specific styling +func (r *Renderer) RenderWorkflow(content string) (string, error) { + // Add workflow header + content = "# Workflow\n\n" + content + return r.Render(content) +} + +// RenderError renders an error message with specific styling +func (r *Renderer) RenderError(title, details, suggestion string) (string, error) { + var content string + + if title != "" { + content += fmt.Sprintf("\n# %s\n\n", title) + } + + if details != "" { + content += fmt.Sprintf("%s\n\n", details) + } + + if suggestion != "" { + if strings.HasPrefix(suggestion, "http") { + content += fmt.Sprintf("For more information, refer to the **docs**\n%s\n", suggestion) + } else { + content += suggestion + } + } + + rendered, err := r.Render(content) + if err != nil { + return "", err + } + + // Remove duplicate URLs and trailing newlines + lines := strings.Split(rendered, "\n") + var result []string + seenURL := false + + // Create a purple style + purpleStyle := termenv.Style{}.Foreground(r.profile.Color(Purple)).Bold() + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.Contains(trimmed, "https://") { + if !seenURL { + seenURL = true + result = append(result, line) + } + } else if strings.HasPrefix(trimmed, "$") { + // Add custom styling for command examples + styled := purpleStyle.Styled(strings.TrimSpace(line)) + result = append(result, " "+styled) + } else if trimmed != "" { + result = append(result, line) + } + } + + // Add a single newline at the end plus extra spacing + return "\n" + strings.Join(result, "\n") + "\n\n", nil +} + +// RenderSuccess renders a success message with specific styling +func (r *Renderer) RenderSuccess(title, details string) (string, error) { + content := fmt.Sprintf("# %s\n\n", title) + + if details != "" { + content += fmt.Sprintf("## Details\n%s\n\n", details) + } + + return r.Render(content) +} + +// Option is a function that configures the renderer +type Option func(*Renderer) + +// WithWidth sets the word wrap width for the renderer +func WithWidth(width uint) Option { + return func(r *Renderer) { + r.width = width + } +} + +// WithColorProfile sets the color profile for the renderer +func WithColorProfile(profile termenv.Profile) Option { + return func(r *Renderer) { + r.profile = profile + } +} diff --git a/pkg/ui/markdown/styles.go b/pkg/ui/markdown/styles.go new file mode 100644 index 000000000..549ef3706 --- /dev/null +++ b/pkg/ui/markdown/styles.go @@ -0,0 +1,276 @@ +package markdown + +import ( + "encoding/json" + + "github.com/charmbracelet/glamour/ansi" + "github.com/cloudposse/atmos/pkg/config" + "github.com/cloudposse/atmos/pkg/schema" +) + +// applyStyleSafely applies a color to a style primitive safely handling nil pointers +func applyStyleSafely(style *ansi.StylePrimitive, color string) { + if style == nil { + return + } + if style.Color != nil { + *style.Color = color + } else { + style.Color = &color + } +} + +// GetDefaultStyle returns the markdown style configuration from atmos.yaml settings +// or falls back to built-in defaults if not configured +func GetDefaultStyle() ([]byte, error) { + atmosConfig, err := config.InitCliConfig(schema.ConfigAndStacksInfo{}, false) + if err != nil { + return getBuiltinDefaultStyle() + } + + // Get the built-in default style + defaultBytes, err := getBuiltinDefaultStyle() + if err != nil { + return nil, err + } + + var style ansi.StyleConfig + if err := json.Unmarshal(defaultBytes, &style); err != nil { + return nil, err + } + + // Apply custom styles on top of defaults + if atmosConfig.Settings.Markdown.Document.Color != "" { + applyStyleSafely(&style.Document.StylePrimitive, atmosConfig.Settings.Markdown.Document.Color) + } + + if atmosConfig.Settings.Markdown.Heading.Color != "" { + applyStyleSafely(&style.Heading.StylePrimitive, atmosConfig.Settings.Markdown.Heading.Color) + style.Heading.Bold = &atmosConfig.Settings.Markdown.Heading.Bold + } + + if atmosConfig.Settings.Markdown.H1.Color != "" { + applyStyleSafely(&style.H1.StylePrimitive, atmosConfig.Settings.Markdown.H1.Color) + if atmosConfig.Settings.Markdown.H1.BackgroundColor != "" { + style.H1.BackgroundColor = &atmosConfig.Settings.Markdown.H1.BackgroundColor + } + style.H1.Bold = &atmosConfig.Settings.Markdown.H1.Bold + style.H1.Margin = uintPtr(uint(atmosConfig.Settings.Markdown.H1.Margin)) + } + + if atmosConfig.Settings.Markdown.H2.Color != "" { + applyStyleSafely(&style.H2.StylePrimitive, atmosConfig.Settings.Markdown.H2.Color) + style.H2.Bold = &atmosConfig.Settings.Markdown.H2.Bold + } + + if atmosConfig.Settings.Markdown.H3.Color != "" { + applyStyleSafely(&style.H3.StylePrimitive, atmosConfig.Settings.Markdown.H3.Color) + style.H3.Bold = &atmosConfig.Settings.Markdown.H3.Bold + } + + if atmosConfig.Settings.Markdown.CodeBlock.Color != "" { + if style.CodeBlock.StyleBlock.StylePrimitive.Color != nil { + *style.CodeBlock.StyleBlock.StylePrimitive.Color = atmosConfig.Settings.Markdown.CodeBlock.Color + } else { + style.CodeBlock.StyleBlock.StylePrimitive.Color = &atmosConfig.Settings.Markdown.CodeBlock.Color + } + style.CodeBlock.Margin = uintPtr(uint(atmosConfig.Settings.Markdown.CodeBlock.Margin)) + } + + if atmosConfig.Settings.Markdown.Link.Color != "" { + applyStyleSafely(&style.Link, atmosConfig.Settings.Markdown.Link.Color) + style.Link.Underline = &atmosConfig.Settings.Markdown.Link.Underline + } + + if atmosConfig.Settings.Markdown.Strong.Color != "" { + applyStyleSafely(&style.Strong, atmosConfig.Settings.Markdown.Strong.Color) + style.Strong.Bold = &atmosConfig.Settings.Markdown.Strong.Bold + } + + if atmosConfig.Settings.Markdown.Emph.Color != "" { + applyStyleSafely(&style.Emph, atmosConfig.Settings.Markdown.Emph.Color) + style.Emph.Italic = &atmosConfig.Settings.Markdown.Emph.Italic + } + + return json.Marshal(style) +} + +// this only returns the built-in default style configuration +func getBuiltinDefaultStyle() ([]byte, error) { + style := ansi.StyleConfig{ + Document: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + BlockPrefix: "", + BlockSuffix: "\n", + Color: stringPtr(White), + }, + Margin: uintPtr(0), + }, + BlockQuote: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: stringPtr(Purple), + }, + Indent: uintPtr(1), + IndentToken: stringPtr("│ "), + }, + Paragraph: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + BlockPrefix: "", + BlockSuffix: "", + Color: stringPtr(White), + }, + }, + List: ansi.StyleList{ + LevelIndent: 4, + }, + Heading: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + BlockPrefix: "", + BlockSuffix: "\n", + Color: stringPtr(Blue), + Bold: boolPtr(true), + }, + Margin: uintPtr(0), + }, + H1: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "", + Color: stringPtr(White), + BackgroundColor: stringPtr(Purple), + Bold: boolPtr(true), + }, + Margin: uintPtr(2), + }, + H2: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "## ", + Color: stringPtr(Purple), + Bold: boolPtr(true), + }, + Margin: uintPtr(1), + }, + H3: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "### ", + Color: stringPtr(Blue), + Bold: boolPtr(true), + }, + }, + H4: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "#### ", + Color: stringPtr(Blue), + Bold: boolPtr(true), + }, + }, + H5: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "##### ", + Color: stringPtr(Blue), + Bold: boolPtr(true), + }, + }, + H6: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Prefix: "###### ", + Color: stringPtr(Blue), + Bold: boolPtr(true), + }, + }, + Text: ansi.StylePrimitive{ + Color: stringPtr(White), + }, + Strong: ansi.StylePrimitive{ + Color: stringPtr(Purple), + Bold: boolPtr(true), + }, + Emph: ansi.StylePrimitive{ + Color: stringPtr(Purple), + Italic: boolPtr(true), + }, + HorizontalRule: ansi.StylePrimitive{ + Color: stringPtr(Purple), + Format: "\n--------\n", + }, + Item: ansi.StylePrimitive{ + BlockPrefix: "• ", + }, + Enumeration: ansi.StylePrimitive{ + BlockPrefix: ". ", + }, + Code: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: stringPtr(Purple), + Prefix: " ", + Bold: boolPtr(true), + }, + Margin: uintPtr(0), + }, + CodeBlock: ansi.StyleCodeBlock{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{ + Color: stringPtr(Blue), + }, + Margin: uintPtr(1), + }, + Chroma: &ansi.Chroma{ + Text: ansi.StylePrimitive{ + Color: stringPtr(Blue), + }, + Keyword: ansi.StylePrimitive{ + Color: stringPtr(Purple), + }, + Literal: ansi.StylePrimitive{ + Color: stringPtr(Blue), + }, + LiteralString: ansi.StylePrimitive{ + Color: stringPtr(Blue), + }, + Name: ansi.StylePrimitive{ + Color: stringPtr(Blue), + }, + LiteralNumber: ansi.StylePrimitive{ + Color: stringPtr(Blue), + }, + Comment: ansi.StylePrimitive{ + Color: stringPtr(Purple), + }, + }, + }, + Table: ansi.StyleTable{ + StyleBlock: ansi.StyleBlock{}, + CenterSeparator: stringPtr("┼"), + ColumnSeparator: stringPtr("│"), + RowSeparator: stringPtr("─"), + }, + DefinitionList: ansi.StyleBlock{}, + DefinitionTerm: ansi.StylePrimitive{}, + DefinitionDescription: ansi.StylePrimitive{ + BlockPrefix: "\n", + }, + HTMLBlock: ansi.StyleBlock{}, + HTMLSpan: ansi.StyleBlock{}, + Link: ansi.StylePrimitive{ + Color: stringPtr(Blue), + Underline: boolPtr(true), + }, + LinkText: ansi.StylePrimitive{ + Color: stringPtr(Purple), + Bold: boolPtr(true), + }, + } + + return json.Marshal(style) +} + +func stringPtr(s string) *string { + return &s +} + +func boolPtr(b bool) *bool { + return &b +} + +func uintPtr(u uint) *uint { + return &u +} diff --git a/website/docs/cli/configuration/configuration.mdx b/website/docs/cli/configuration/configuration.mdx index 9268c0cb3..4f8570d02 100644 --- a/website/docs/cli/configuration/configuration.mdx +++ b/website/docs/cli/configuration/configuration.mdx @@ -105,6 +105,11 @@ templates: # https://docs.gomplate.ca gomplate: enabled: true +settings: + list_merge_strategy: replace + terminal: + max_width: 120 # Maximum width for terminal output + pager: true # Use pager for long output ``` @@ -151,13 +156,11 @@ The `settings` section configures Atmos global settings. # If the source and destination lists have the same length, all items in the destination lists are # deep-merged with all items in the source list. list_merge_strategy: replace - # `docs` specifies how component documentation is displayed in the terminal. - # The following documentation display settings are supported: - # `max-width`: The maximum width for displaying component documentation in the terminal. - # 'pagination`: When enabled, displays component documentation in a pager instead of directly in the terminal. - docs: - max-width: 80 - pagination: true + + # Terminal settings for displaying content + terminal: + max_width: 120 # Maximum width for terminal output + pager: true # Use pager for long output ``` @@ -179,17 +182,32 @@ The `settings` section configures Atmos global settings. -
`settings.docs`
+
`settings.terminal`
- Specifies how component documentation is displayed in the terminal. + Specifies how content is displayed in the terminal. The following settings are supported:
-
`max-width`
-
The maximum width for displaying component documentation in the terminal.
+
`max_width`
+
The maximum width for displaying content in the terminal.
+ +
`pager`
+
When enabled, displays long content in a pager instead of directly in the terminal.
+
+
+ +
`settings.docs` (Deprecated)
+
+ :::warning Deprecated + The `settings.docs` section is deprecated and will be removed in a future version. Please use `settings.terminal` instead. + ::: + +
+
`max-width` (Deprecated)
+
Use `settings.terminal.max_width` instead.
-
`pagination`
-
When enabled, displays component documentation in a pager instead of directly in the terminal.
+
`pagination` (Deprecated)
+
Use `settings.terminal.pager` instead.
diff --git a/website/docs/cli/configuration/markdown-styling.mdx b/website/docs/cli/configuration/markdown-styling.mdx new file mode 100644 index 000000000..5d5bd9746 --- /dev/null +++ b/website/docs/cli/configuration/markdown-styling.mdx @@ -0,0 +1,273 @@ +--- +title: Markdown Styling +sidebar_label: Markdown Styling +sidebar_position: 7 +description: Configure custom markdown styling for Atmos CLI output +--- + +import File from '@site/src/components/File' +import Intro from '@site/src/components/Intro' + +# Markdown Styling + + +Configure how Atmos displays markdown content in the terminal. + + +## Configuration + +Configure markdown styling in your `atmos.yaml` configuration file: + + +```yaml +settings: + # Terminal settings for displaying content + terminal: + max_width: 120 # Maximum width for terminal output + pager: true # Use pager for long output + timestamps: false + colors: true + unicode: true + + # Markdown element styling + markdown: + document: + color: "${colors.text}" + heading: + color: "${colors.primary}" + bold: true + code_block: + color: "${colors.secondary}" + margin: 1 + link: + color: "${colors.primary}" + underline: true + strong: + color: "${colors.secondary}" + bold: true + emph: + color: "${colors.muted}" + italic: true + +``` + + +## Style Properties + +Each markdown element supports the following properties: + +### Common Properties + +| Property | Type | Description | +|----------|------|-------------| +| `color` | string | Text color in hex format (e.g., "#FFFFFF") | +| `background_color` | string | Background color in hex format | +| `bold` | boolean | Whether to make the text bold | +| `italic` | boolean | Whether to make the text italic | +| `underline` | boolean | Whether to underline the text | +| `margin` | number | Space around the element | +| `indent` | number | Indentation level | + +### Element-Specific Properties + +#### Document + +Base styling for all text content. + +Supports all common properties. + +#### Headings (H1-H6) + +Individual styling for each heading level (1-6). + + ```markdown + # Heading 1 + ## Heading 2 + ### Heading 3 + etc... + ``` + +**Supports:** +- H1 supports additional `background_color` property +- All heading levels support `margin` for vertical spacing + +#### Code Blocks + +Styling for multi-line code blocks (aka code fences). + +````markdown +``` +this is a codeblock +``` +```` + +**Supports:** +- `margin` for visual separation +- Color applies to the entire block + +#### Block Quotes + +Styling for quoted text. Supports all common properties. + +```markdown +> +> This is quoted text +> +``` + +**Supports:** +- `indent` property controls quote indentation + +#### Links + +Styling for hyperlinks. + +``` +[This is a link](https://example.com/) +``` + +**Supports:** +- `underline` property specifically for links +- Color applies to both link text and underline + +## Default Styles + +If no custom styles are configured, Atmos uses a built-in default theme related to the default atmos brand colors: + +```yaml +# Built-in default theme +settings: + markdown: + document: + color: "#FFFFFF" # White text + heading: + color: "#00A3E0" # Blue headings + bold: true + h1: + color: "#FFFFFF" # White text + background_color: "#9B51E0" # Purple background + bold: true + margin: 2 + code_block: + color: "#00A3E0" # Blue code + margin: 1 + link: + color: "#00A3E0" # Blue links + underline: true +``` + +## Terminal Compatibility + +Atmos uses [termenv](https://github.com/muesli/termenv) and [glamour](https://github.com/charmbracelet/glamour) to automatically detect and adapt to your terminal's capabilities: + +- **Full Color Support (24-bit)** + - Renders exact hex colors as specified in your config + - Detected via `$COLORTERM=truecolor` or `$TERM` containing `24bit`/`truecolor` + - Examples: iTerm2, Terminal.app, Windows Terminal + +- **256 Color Support** + - Automatically maps hex colors to nearest ANSI 256 colors + - Detected via `$TERM` containing `256color` + - Examples: xterm-256color terminals + +- **Basic Color Support (8/16 colors)** + - Automatically maps to basic ANSI colors + - Used when `$TERM` indicates basic terminal + - Examples: xterm, vt100, basic SSH sessions + +- **No Color Support** + - Falls back to plain text with basic formatting + - Used when `$TERM=dumb` or no color support detected + - Examples: Basic terminals, some CI environments + +The color degradation is handled automatically by termenv's color profile detection. You don't need to configure anything - your styles will work everywhere, automatically adjusting to each terminal's capabilities. + +## Examples + +### Error Messages +Custom styling can help distinguish different types of messages: + +```yaml +settings: + markdown: + # General heading styles + heading: + color: "#00A3E0" # Blue for standard headings + bold: true + + # Code blocks for command examples + code_block: + color: "#00FFFF" # Cyan for code examples + margin: 1 + + # Emphasized text for warnings/errors + emph: + color: "#FF6B6B" # Red for emphasis in error messages + italic: true + + # Strong text for important messages + strong: + color: "#FF6B6B" # Red for important parts + bold: true +``` + +### Help Text + +Atmos uses the [Glamour](https://github.com/charmbracelet/glamour) library for markdown rendering and styling. The styling is handled automatically based on your terminal's capabilities and color profile. + +Key features of the markdown rendering: + +- **Auto-styling**: Adapts to your terminal's color scheme +- **Word wrapping**: Automatically adjusts to terminal width +- **Emoji support**: Renders emoji characters when available +- **Rich formatting**: Supports headings, code blocks, links, and other markdown elements + +The styling is managed internally by Glamour and does not require manual configuration in your atmos settings. + +## Best Practices + +1. **Color Contrast**: Ensure sufficient contrast between text and background colors for readability. +2. **Consistent Styling**: Use a consistent color scheme across different elements. +3. **Terminal Support**: Test your styling in different terminals to ensure compatibility. +4. **Accessibility**: Consider color-blind users when choosing your color scheme. + +## Troubleshooting + +1. **Verify Terminal Supports True Color:** + + - **Check `$COLORTERM`:** + ```bash + echo $COLORTERM + ``` + **Expected Output:** `truecolor` or `24bit` + + - **Check `$TERM`:** + ```bash + echo $TERM + ``` + **Recommended Values:** `xterm-256color`, `xterm-direct`, `xterm-truecolor` + +2. **Ensure Your Terminal Emulator Supports True Color:** + + - Use a terminal emulator known for true color support (e.g., Terminal.app, iTerm2, Windows Terminal, etc). + +3. **Configure Environment Variables Correctly:** + + - Set `$TERM` to a value that supports true color: + ```bash + export TERM=xterm-256color + ``` + Add this to your shell's configuration file (`~/.bashrc`, `~/.zshrc`, etc.) to make it permanent. + +4. **Validate `atmos.yaml` Configuration:** + + - Ensure colors are in hex format, boolean values are `true`/`false` (not quoted strings), and numbers are integers. + - Use a YAML linter to validate the syntax. + - Try removing custom styles to see if default styles work. + + + +## See Also + +- [CLI Configuration](/cli/configuration) +- [Command Reference](/cli/commands) \ No newline at end of file