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:
parent
aeae34365c
commit
800ab8464a
7 changed files with 262 additions and 13 deletions
130
cmd/markdown.go
Normal file
130
cmd/markdown.go
Normal 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
77
cmd/read.go
Normal 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
6
go.mod
|
|
@ -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
6
go.sum
|
|
@ -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
11
main.go
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -10,4 +10,7 @@ const (
|
|||
Cyan = "\033[36m"
|
||||
Gray = "\033[90m"
|
||||
Bold = "\033[1m"
|
||||
Italic = "\033[3m"
|
||||
Dim = "\033[2m"
|
||||
Underline = "\033[4m"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue