mirror of
https://github.com/charmbracelet/glow.git
synced 2026-08-23 08:34:17 +02:00
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.
This commit is contained in:
parent
53788271b3
commit
17571ba0aa
3 changed files with 368 additions and 1 deletions
73
glow_test.go
73
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"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
main.go
2
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() {
|
||||
|
|
|
|||
294
style_cmd.go
Normal file
294
style_cmd.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue