feat: add syntax highlighting and markdown rendering

- Add 'z read' command with chroma syntax highlighting and line numbers
- Add 'z md' command for rendering markdown to terminal
- Add Italic, Dim, Underline color constants
- Move ReadFileContent to shared pkg/formatter/utils.go
- Custom markdown parser with colored headings, lists, checkboxes
- Code blocks in markdown use chroma syntax highlighting
This commit is contained in:
selamanapps 2026-05-02 00:43:22 +03:00
parent aeae34365c
commit 800ab8464a
7 changed files with 262 additions and 13 deletions

130
cmd/markdown.go Normal file
View file

@ -0,0 +1,130 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/zemenawi/zutils/pkg/colors"
"github.com/zemenawi/zutils/pkg/formatter"
)
func MarkdownCommand(args []string) error {
if len(args) < 1 {
return fmt.Errorf("please specify a markdown file path")
}
filePath := args[0]
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("error opening file: %w", err)
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return fmt.Errorf("error getting file info: %w", err)
}
if fileInfo.IsDir() {
return fmt.Errorf("'%s' is a directory, not a readable file", filePath)
}
ext := filepath.Ext(filePath)
if ext != ".md" && ext != ".markdown" {
return fmt.Errorf("'%s' is not a markdown file", filePath)
}
content := formatter.ReadFileContent(file)
fmt.Printf("%s%s╔══════════════════════════════════════╗%s\n", colors.Cyan, colors.Bold, colors.Reset)
fmt.Printf("%s%s║%s %-38s %s%s║%s\n", colors.Cyan, colors.Bold, colors.Reset, filePath, colors.Cyan, colors.Bold, colors.Reset)
fmt.Printf("%s%s╚══════════════════════════════════════╝%s\n", colors.Cyan, colors.Bold, colors.Reset)
fmt.Println()
lines := strings.Split(content, "\n")
inCodeBlock := false
codeBuffer := &strings.Builder{}
codeLang := ""
for i, line := range lines {
if strings.HasPrefix(line, "```") {
if !inCodeBlock {
inCodeBlock = true
codeLang = strings.TrimPrefix(line, "```")
codeBuffer.Reset()
} else {
inCodeBlock = false
renderCodeBlock(codeBuffer.String(), codeLang)
codeBuffer.Reset()
codeLang = ""
}
continue
}
if inCodeBlock {
if codeBuffer.Len() > 0 {
codeBuffer.WriteString("\n")
}
codeBuffer.WriteString(line)
continue
}
if strings.HasPrefix(line, "# ") {
fmt.Printf("%s%s%s%s\n", colors.Bold, colors.Yellow, line[2:], colors.Reset)
} else if strings.HasPrefix(line, "## ") {
fmt.Printf("%s%s%s%s\n", colors.Bold, colors.Green, line[3:], colors.Reset)
} else if strings.HasPrefix(line, "### ") {
fmt.Printf("%s%s%s%s\n", colors.Bold, colors.Cyan, line[4:], colors.Reset)
} else if strings.HasPrefix(line, "- [ ]") {
fmt.Printf(" %s☐%s %s\n", colors.Gray, colors.Reset, line[5:])
} else if strings.HasPrefix(line, "- [x]") {
fmt.Printf(" %s☑%s %s\n", colors.Green, colors.Reset, line[5:])
} else if strings.HasPrefix(line, "- ") && !strings.HasPrefix(line, "- [") {
fmt.Printf(" %s•%s %s\n", colors.Blue, colors.Reset, line[2:])
} else if strings.HasPrefix(line, "> ") {
fmt.Printf("%s%s%s%s\n", colors.Gray, colors.Italic, line[2:], colors.Reset)
} else if strings.TrimSpace(line) != "" {
fmt.Printf("%s\n", line)
} else {
fmt.Println()
}
_ = i
}
return nil
}
func renderCodeBlock(code, lang string) {
if code == "" {
return
}
lexer := lexers.Match(lang)
if lexer == nil {
lexer = lexers.Fallback
}
iterator, _ := lexer.Tokenise(nil, code)
formatter := formatters.TTY256
style := styles.Get("monokai")
if style == nil {
style = styles.Fallback
}
var buf strings.Builder
formatter.Format(&buf, style, iterator)
output := buf.String()
for _, line := range strings.Split(output, "\n") {
if line != "" {
fmt.Printf("%s%s│ %s%s\n", colors.Gray, colors.Dim, colors.Reset, line)
}
}
fmt.Println()
}

77
cmd/read.go Normal file
View file

@ -0,0 +1,77 @@
package cmd
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/zemenawi/zutils/pkg/colors"
"github.com/zemenawi/zutils/pkg/formatter"
)
func ReadCommand(args []string) error {
if len(args) < 1 {
return fmt.Errorf("please specify a file path")
}
filePath := args[0]
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("error opening file: %w", err)
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return fmt.Errorf("error getting file info: %w", err)
}
if fileInfo.IsDir() {
return fmt.Errorf("'%s' is a directory, not a readable file", filePath)
}
content := formatter.ReadFileContent(file)
lexer := lexers.Match(filepath.Base(filePath))
if lexer == nil {
lexer = lexers.Fallback
}
iterator, err := lexer.Tokenise(nil, content)
if err != nil {
return fmt.Errorf("error tokenizing: %w", err)
}
formatter_ := formatters.TTY256
style := styles.Get("monokai")
if style == nil {
style = styles.Fallback
}
fmt.Printf("%s%s╔══════════════════════════════════════╗%s\n", colors.Cyan, colors.Bold, colors.Reset)
fmt.Printf("%s%s║%s %-38s %s%s║%s\n", colors.Cyan, colors.Bold, colors.Reset, filePath, colors.Cyan, colors.Bold, colors.Reset)
fmt.Printf("%s%s╚══════════════════════════════════════╝%s\n", colors.Cyan, colors.Bold, colors.Reset)
fmt.Println()
var buf bytes.Buffer
err = formatter_.Format(&buf, style, iterator)
if err != nil {
return fmt.Errorf("error formatting: %w", err)
}
lines := strings.Split(buf.String(), "\n")
for i, line := range lines {
if line != "" {
fmt.Printf("%s%4d: %s%s\n", colors.Gray, i+1, colors.Reset, line)
}
}
return nil
}

6
go.mod
View file

@ -1,3 +1,9 @@
module github.com/zemenawi/zutils
go 1.22.0
require (
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
)

6
go.sum
View file

@ -0,0 +1,6 @@
github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM=
github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=

11
main.go
View file

@ -12,6 +12,16 @@ func main() {
registry := types.NewCommandRegistry()
// Register all commands
registry.Register(&types.Command{
Name: "read",
Description: "Read and display file with syntax highlighting",
Handler: cmd.ReadCommand,
})
registry.Register(&types.Command{
Name: "md",
Description: "Render markdown file to terminal",
Handler: cmd.MarkdownCommand,
})
cmd.RegisterInfoCommands(registry)
registry.Register(&types.Command{
Name: "version",
@ -74,6 +84,7 @@ func printUsage(registry *types.CommandRegistry) {
}
fmt.Println()
fmt.Println("Quick Examples:")
fmt.Println(" z read go.mod # Display file with line numbers")
fmt.Println(" z info main.go # File information")
fmt.Println(" z info . # Directory information")
fmt.Println(" z info network # Network information")

View file

@ -10,4 +10,7 @@ const (
Cyan = "\033[36m"
Gray = "\033[90m"
Bold = "\033[1m"
Italic = "\033[3m"
Dim = "\033[2m"
Underline = "\033[4m"
)

View file

@ -3,6 +3,7 @@ package formatter
import (
"fmt"
"math"
"os"
)
// FormatSize converts bytes to human-readable format
@ -43,3 +44,18 @@ func repeat(s string, count int) string {
}
return result
}
func ReadFileContent(file *os.File) string {
content := make([]byte, 0)
buffer := make([]byte, 4096)
for {
n, err := file.Read(buffer)
if n > 0 {
content = append(content, buffer[:n]...)
}
if err != nil {
break
}
}
return string(content)
}