From 17571ba0aaaf6936cc881ebd0d161e2420707aa8 Mon Sep 17 00:00:00 2001 From: britz Date: Sat, 13 Jun 2026 16:44:23 +0530 Subject: [PATCH] feat: add 'glow style init' interactive command to create custom color styles Add an interactive CLI command 'glow style init' that walks users through creating a custom glamour JSON stylesheet. Users are prompted for colors for key markdown elements (document text, headings, code, links, etc.) and the command generates a valid stylesheet that can be used with 'glow -s path/to/style.json'. Fixes are included for the generated JSON: ANSI color codes and hex colors are supported, and the output matches the glamour JSON format exactly. --- glow_test.go | 73 +++++++++++++ main.go | 2 +- style_cmd.go | 294 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 style_cmd.go diff --git a/glow_test.go b/glow_test.go index 8743be2..621ad1f 100644 --- a/glow_test.go +++ b/glow_test.go @@ -39,3 +39,76 @@ func TestGlowFlags(t *testing.T) { } } } + +func TestBuildStyle(t *testing.T) { + style := buildStyle("252", "228", "63", true, "39", "39", "35", + "203", "236", "244", "30", true, "240", "│ ") + + tests := []struct { + key string + field string + expected interface{} + }{ + {"document", "color", "252"}, + {"document", "margin", 2}, + {"h1", "color", "228"}, + {"h1", "background_color", "63"}, + {"h1", "bold", true}, + {"h2", "prefix", "## "}, + {"code", "color", "203"}, + {"code", "background_color", "236"}, + {"link", "color", "30"}, + {"link", "underline", true}, + {"hr", "color", "240"}, + {"block_quote", "indent_token", "│ "}, + {"strong", "bold", true}, + {"emph", "italic", true}, + {"strikethrough", "crossed_out", true}, + {"task", "ticked", "[✓] "}, + {"task", "unticked", "[ ] "}, + } + + for _, tt := range tests { + section, ok := style[tt.key].(map[string]interface{}) + if !ok { + t.Errorf("missing style section: %s", tt.key) + continue + } + got, ok := section[tt.field] + if !ok { + t.Errorf("missing field %s in section %s", tt.field, tt.key) + continue + } + if got != tt.expected { + t.Errorf("style[%s].%s = %v, want %v", tt.key, tt.field, got, tt.expected) + } + } +} + +func TestBuildStyleCustomColors(t *testing.T) { + style := buildStyle("#FF0000", "#00FF00", "#0000FF", false, "#FF00FF", "#FFFF00", "#00FFFF", + "#FFA500", "#800080", "#A52A2A", "#FFC0CB", false, "#808080", "> ") + + doc := style["document"].(map[string]interface{}) + if doc["color"] != "#FF0000" { + t.Errorf("document.color = %v, want #FF0000", doc["color"]) + } + + h1 := style["h1"].(map[string]interface{}) + if h1["color"] != "#00FF00" { + t.Errorf("h1.color = %v, want #00FF00", h1["color"]) + } + if h1["bold"] != false { + t.Errorf("h1.bold = %v, want false", h1["bold"]) + } + + link := style["link"].(map[string]interface{}) + if link["underline"] != false { + t.Errorf("link.underline = %v, want false", link["underline"]) + } + + bq := style["block_quote"].(map[string]interface{}) + if bq["indent_token"] != "> " { + t.Errorf("block_quote.indent_token = %v, want '> '", bq["indent_token"]) + } +} diff --git a/main.go b/main.go index b31ca15..93ae916 100644 --- a/main.go +++ b/main.go @@ -425,7 +425,7 @@ func init() { viper.SetDefault("width", 0) viper.SetDefault("all", true) - rootCmd.AddCommand(configCmd, manCmd) + rootCmd.AddCommand(configCmd, manCmd, styleCmd) } func tryLoadConfigFromDefaultPlaces() { diff --git a/style_cmd.go b/style_cmd.go new file mode 100644 index 0000000..133477a --- /dev/null +++ b/style_cmd.go @@ -0,0 +1,294 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/mitchellh/go-homedir" + "github.com/spf13/cobra" +) + +var styleOutputPath string + +var styleCmd = &cobra.Command{ + Use: "style", + Short: "Manage glow color styles", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, +} + +var styleInitCmd = &cobra.Command{ + Use: "init", + Short: "Create a custom color style interactively", + Long: paragraph(fmt.Sprintf( + "\nWalk through interactive prompts to create a custom %s JSON stylesheet with your preferred colors.\n\nThe generated file can be used with %s.", + keyword("glamour"), + keyword("glow -s path/to/style.json"), + )), + Example: paragraph(" glow style init\n glow style init -o ~/mytheme.json"), + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { + return runStyleInit() + }, +} + +func init() { + styleInitCmd.Flags().StringVarP(&styleOutputPath, "output", "o", "", "output path for the style JSON (default ~/.config/glow/style.json)") + styleCmd.AddCommand(styleInitCmd) +} + +func runStyleInit() error { + if styleOutputPath == "" { + home, err := homedir.Dir() + if err != nil { + return fmt.Errorf("unable to determine home directory: %w", err) + } + styleOutputPath = filepath.Join(home, ".config", "glow", "style.json") + } + + fmt.Println() + fmt.Println(" " + keyword("glow style init")) + fmt.Println(" " + strings.Repeat("─", 40)) + fmt.Println(" Press Enter to accept the default value shown in brackets.") + fmt.Println() + + isDark := promptChoose("Base scheme", "1", []string{ + "Dark background", + "Light background", + }) == "1" + + docColor := promptColor("Document text color", "252", isDark) + h1Color := promptColor("H1 text color", "228", isDark) + h1Bg := promptColor("H1 background color", "63", isDark) + h1Bold := promptBool("H1 bold", true) + h2Color := promptColor("H2 text color", "39", isDark) + h3Color := promptColor("H3 text color", "39", isDark) + h6Color := promptColor("H6 text color", "35", isDark) + codeColor := promptColor("Inline code text color", "203", isDark) + codeBg := promptColor("Inline code background color", "236", isDark) + codeBlockColor := promptColor("Code block text color", "244", isDark) + linkColor := promptColor("Link color", "30", isDark) + linkUnderline := promptBool("Underline links", true) + hrColor := promptColor("Horizontal rule color", "240", isDark) + blockQuoteToken := prompt("Blockquote indent token", "│ ") + + style := buildStyle(docColor, h1Color, h1Bg, h1Bold, h2Color, h3Color, h6Color, + codeColor, codeBg, codeBlockColor, linkColor, linkUnderline, hrColor, blockQuoteToken) + + data, err := json.MarshalIndent(style, "", " ") + if err != nil { + return fmt.Errorf("unable to marshal style: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(styleOutputPath), 0o700); err != nil { + return fmt.Errorf("unable to create directory: %w", err) + } + + if err := os.WriteFile(styleOutputPath, data, 0o600); err != nil { + return fmt.Errorf("unable to write style file: %w", err) + } + + fmt.Println() + fmt.Println(" ✓ Style saved to: " + styleOutputPath) + fmt.Println(" Use it with: " + keyword("glow -s "+styleOutputPath)) + fmt.Println() + + return nil +} + +func prompt(label, defaultVal string) string { + fmt.Printf(" %s [%s]: ", label, defaultVal) + var input string + _, _ = fmt.Scanln(&input) + input = strings.TrimSpace(input) + if input == "" { + return defaultVal + } + return input +} + +func promptColor(label, defaultColor string, isDark bool) string { + hint := defaultColor + fmt.Printf(" %s [%s]: ", label, hint) + var input string + _, _ = fmt.Scanln(&input) + input = strings.TrimSpace(input) + if input == "" { + return defaultColor + } + return input +} + +func promptBool(label string, defaultVal bool) bool { + defaultStr := "y" + if !defaultVal { + defaultStr = "n" + } + fmt.Printf(" %s? [%s]: ", label, defaultStr) + var input string + _, _ = fmt.Scanln(&input) + input = strings.TrimSpace(strings.ToLower(input)) + if input == "" { + return defaultVal + } + return input == "y" || input == "yes" +} + +func promptChoose(label, defaultVal string, options []string) string { + fmt.Println(" " + label + ":") + for i, opt := range options { + fmt.Printf(" %d) %s\n", i+1, opt) + } + fmt.Printf(" Enter 1-%d [%s]: ", len(options), defaultVal) + var input string + _, _ = fmt.Scanln(&input) + input = strings.TrimSpace(input) + if input == "" { + return defaultVal + } + return input +} + +func buildStyle(docColor, h1Color, h1Bg string, h1Bold bool, h2Color, h3Color, h6Color string, + codeColor, codeBg, codeBlockColor, linkColor string, linkUnderline bool, hrColor, blockQuoteToken string) map[string]interface{} { + + style := map[string]interface{}{ + "document": map[string]interface{}{ + "block_prefix": "\n", + "block_suffix": "\n", + "color": docColor, + "margin": 2, + }, + "block_quote": map[string]interface{}{ + "indent": 1, + "indent_token": blockQuoteToken, + }, + "paragraph": map[string]interface{}{}, + "list": map[string]interface{}{ + "level_indent": 2, + }, + "heading": map[string]interface{}{ + "block_suffix": "\n", + "color": h2Color, + "bold": true, + }, + "h1": map[string]interface{}{ + "prefix": " ", + "suffix": " ", + "color": h1Color, + "background_color": h1Bg, + "bold": h1Bold, + }, + "h2": map[string]interface{}{ + "prefix": "## ", + }, + "h3": map[string]interface{}{ + "prefix": "### ", + "color": h3Color, + }, + "h4": map[string]interface{}{ + "prefix": "#### ", + }, + "h5": map[string]interface{}{ + "prefix": "##### ", + }, + "h6": map[string]interface{}{ + "prefix": "###### ", + "color": h6Color, + "bold": false, + }, + "text": map[string]interface{}{}, + "strikethrough": map[string]interface{}{ + "crossed_out": true, + }, + "emph": map[string]interface{}{ + "italic": true, + }, + "strong": map[string]interface{}{ + "bold": true, + }, + "hr": map[string]interface{}{ + "color": hrColor, + "format": "\n--------\n", + }, + "item": map[string]interface{}{ + "block_prefix": "• ", + }, + "enumeration": map[string]interface{}{ + "block_prefix": ". ", + }, + "task": map[string]interface{}{ + "ticked": "[✓] ", + "unticked": "[ ] ", + }, + "link": map[string]interface{}{ + "color": linkColor, + "underline": linkUnderline, + }, + "link_text": map[string]interface{}{ + "color": "35", + "bold": true, + }, + "image": map[string]interface{}{ + "color": "212", + "underline": true, + }, + "image_text": map[string]interface{}{ + "color": "243", + "format": "Image: {{.text}} →", + }, + "code": map[string]interface{}{ + "prefix": "\u00a0", + "suffix": "\u00a0", + "color": codeColor, + "background_color": codeBg, + }, + "code_block": map[string]interface{}{ + "color": codeBlockColor, + "margin": 2, + "chroma": map[string]interface{}{ + "text": map[string]interface{}{"color": "#C4C4C4"}, + "error": map[string]interface{}{"color": "#F1F1F1", "background_color": "#F05B5B"}, + "comment": map[string]interface{}{"color": "#676767"}, + "comment_preproc": map[string]interface{}{"color": "#FF875F"}, + "keyword": map[string]interface{}{"color": "#00AAFF"}, + "keyword_reserved": map[string]interface{}{"color": "#FF5FD2"}, + "keyword_namespace": map[string]interface{}{"color": "#FF5F87"}, + "keyword_type": map[string]interface{}{"color": "#6E6ED8"}, + "operator": map[string]interface{}{"color": "#EF8080"}, + "punctuation": map[string]interface{}{"color": "#E8E8A8"}, + "name": map[string]interface{}{"color": "#C4C4C4"}, + "name_builtin": map[string]interface{}{"color": "#FF8EC7"}, + "name_tag": map[string]interface{}{"color": "#B083EA"}, + "name_attribute": map[string]interface{}{"color": "#7A7AE6"}, + "name_class": map[string]interface{}{"color": "#F1F1F1", "underline": true, "bold": true}, + "name_decorator": map[string]interface{}{"color": "#FFFF87"}, + "name_function": map[string]interface{}{"color": "#00D787"}, + "literal_number": map[string]interface{}{"color": "#6EEFC0"}, + "literal_string": map[string]interface{}{"color": "#C69669"}, + "literal_string_escape": map[string]interface{}{"color": "#AFFFD7"}, + "generic_deleted": map[string]interface{}{"color": "#FD5B5B"}, + "generic_emph": map[string]interface{}{"italic": true}, + "generic_inserted": map[string]interface{}{"color": "#00D787"}, + "generic_strong": map[string]interface{}{"bold": true}, + "generic_subheading": map[string]interface{}{"color": "#777777"}, + "background": map[string]interface{}{"background_color": "#373737"}, + }, + }, + "table": map[string]interface{}{}, + "definition_list": map[string]interface{}{}, + "definition_term": map[string]interface{}{}, + "definition_description": map[string]interface{}{ + "block_prefix": "\n🠶 ", + }, + "html_block": map[string]interface{}{}, + "html_span": map[string]interface{}{}, + } + + return style +}