From e643aa465ab14ed8cc5648b1b0798a68956d4e6b Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Tue, 30 Jun 2026 00:40:13 +0530 Subject: [PATCH 1/8] feat(render): Implment package render --- internal/render/doc.go | 1 + internal/render/page.go | 199 +++++++++++++++++++++ internal/render/page_test.go | 335 +++++++++++++++++++++++++++++++++++ internal/render/render.go | 332 ++++++++++++++++++++++++++++++++++ 4 files changed, 867 insertions(+) create mode 100644 internal/render/doc.go create mode 100644 internal/render/page.go create mode 100644 internal/render/page_test.go create mode 100644 internal/render/render.go diff --git a/internal/render/doc.go b/internal/render/doc.go new file mode 100644 index 0000000..da4cad7 --- /dev/null +++ b/internal/render/doc.go @@ -0,0 +1 @@ +package render diff --git a/internal/render/page.go b/internal/render/page.go new file mode 100644 index 0000000..37c0fb1 --- /dev/null +++ b/internal/render/page.go @@ -0,0 +1,199 @@ +package render + +import ( + "regexp" + "strings" + + "github.com/TheRootDaemon/tlgc/internal/config" +) + +var ( + placeholderPattern = regexp.MustCompile(`\{\{(.*?)\}\}`) + optionPattern = regexp.MustCompile(`^\[(.+)\|(.+)\]$`) +) + +type Kind int + +const ( + Text Kind = iota + Placeholder + Option +) + +type Segment struct { + Kind Kind + Text string + Short string + Long string +} + +type Example struct { + Description string + Command string +} + +type Page struct { + Title string + URL string + Path string + Description []string + Examples []Example +} + +func Parse(content string) *Page { + p := &Page{} + lines := strings.SplitSeq(content, "\n") + + for line := range lines { + switch { + case strings.HasPrefix(line, "# ") && p.Title == "": + p.Title = line[2:] + case strings.HasPrefix(line, "> "): + body := line[2:] + if url := parseURL(body); url != "" { + p.URL = url + } else { + p.Description = append(p.Description, body) + } + case strings.HasPrefix(line, "- "): + desc := strings.TrimSuffix(line[2:], ":") + p.Examples = append( + p.Examples, + Example{ + Description: desc, + }, + ) + case strings.HasPrefix(line, "`") && strings.HasSuffix(line, "`"): + cmd := strings.TrimPrefix( + strings.TrimSuffix(line, "`"), + "`", + ) + + if len(p.Examples) == 0 { + p.Examples = append( + p.Examples, + Example{ + Command: cmd, + }, + ) + } else { + last := &p.Examples[len(p.Examples)-1] + if last.Command == "" { + last.Command = cmd + } + } + } + } + + return p +} + +func ParseCommand(raw string) []Segment { + if raw == "" { + return nil + } + + matches := placeholderPattern.FindAllStringSubmatchIndex(raw, -1) + if len(matches) == 0 { + return []Segment{ + { + Kind: Text, + Text: raw, + }, + } + } + + lastEnd := 0 + var segments []Segment + + for _, match := range matches { + matchStart := match[0] + matchEnd := match[1] + groupStart := match[2] + groupEnd := match[3] + + if lastEnd < matchStart { + segments = append( + segments, + Segment{ + Kind: Text, + Text: raw[lastEnd:matchStart], + }, + ) + } + + inner := raw[groupStart:groupEnd] + segments = append( + segments, + parseInnerPlaceholders(inner), + ) + + lastEnd = matchEnd + } + + if lastEnd < len(raw) { + segments = append( + segments, + Segment{ + Kind: Text, + Text: raw[lastEnd:], + }, + ) + } + + return segments +} + +func (s Segment) DisplayText(style config.OptionStyle) string { + switch s.Kind { + case Option: + switch style { + case config.OptionStyleShort: + return s.Short + case config.OptionStyleLong: + return s.Long + case config.OptionStyleCombined: + return "[" + s.Short + "|" + s.Long + "]" + default: + return s.Long + } + default: + return s.Text + } +} + +func parseURL(body string) string { + if !strings.Contains(body, "More information: <") { + return "" + } + + start := strings.Index(body, "<") + end := strings.Index(body, ">") + if start != -1 && end != -1 && start < end { + return body[start+1 : end] + } + + return "" +} + +func parseInnerPlaceholders(inner string) Segment { + if options := optionPattern.FindStringSubmatch(inner); len(options) == 3 { + left, right := options[1], options[2] + short, long := left, right + + if strings.HasPrefix(left, "--") && !strings.HasPrefix(right, "--") { + short, long = right, left + } + + return Segment{ + Kind: Option, + Long: long, + Short: short, + } + } + + return Segment{ + Kind: Placeholder, + Text: inner, + } +} diff --git a/internal/render/page_test.go b/internal/render/page_test.go new file mode 100644 index 0000000..7583674 --- /dev/null +++ b/internal/render/page_test.go @@ -0,0 +1,335 @@ +package render + +import ( + "testing" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want *Page + }{ + { + name: "full page with title description URL and examples", + content: "# tar\n\n> archive utility.\n> More information: .\n\n- create an archive:\n\n`tar cf {{archive.tar}} {{files}}`\n\n- extract an archive:\n\n`tar xf {{archive.tar}}`\n", + want: &Page{ + Title: "tar", + Description: []string{"archive utility."}, + URL: "https://example.org/tar", + Examples: []Example{ + {Description: "create an archive", Command: "tar cf {{archive.tar}} {{files}}"}, + {Description: "extract an archive", Command: "tar xf {{archive.tar}}"}, + }, + }, + }, + { + name: "title only", + content: "# title-only\n", + want: &Page{ + Title: "title-only", + }, + }, + { + name: "description without URL", + content: "# desc-test\n\n> just a description.\n", + want: &Page{ + Title: "desc-test", + Description: []string{"just a description."}, + }, + }, + { + name: "multiple description lines merged with space", + content: "# multi-desc\n\n> line one.\n> line two.\n> line three.\n", + want: &Page{ + Title: "multi-desc", + Description: []string{"line one.", "line two.", "line three."}, + }, + }, + { + name: "example description without trailing colon", + content: "# no-colon\n\n- description without colon\n\n`echo hello`\n", + want: &Page{ + Title: "no-colon", + Examples: []Example{ + {Description: "description without colon", Command: "echo hello"}, + }, + }, + }, + { + name: "command with placeholders", + content: "# placeholders\n\n- example:\n\n`tar cf {{archive.tar}} {{files}}`\n", + want: &Page{ + Title: "placeholders", + Examples: []Example{ + {Description: "example", Command: "tar cf {{archive.tar}} {{files}}"}, + }, + }, + }, + { + name: "command without placeholder", + content: "# plain\n\n- example:\n\n`ls -la`\n", + want: &Page{ + Title: "plain", + Examples: []Example{ + {Description: "example", Command: "ls -la"}, + }, + }, + }, + { + name: "URL extracted from More information line", + content: "# with-url\n\n> A description.\n> More information: .\n", + want: &Page{ + Title: "with-url", + Description: []string{"A description."}, + URL: "https://example.org/custom", + }, + }, + { + name: "empty content", + content: "", + want: &Page{}, + }, + { + name: "example without command", + content: "# no-command\n\n- description only:\n", + want: &Page{ + Title: "no-command", + Examples: []Example{ + {Description: "description only"}, + }, + }, + }, + { + name: "command without preceding description", + content: "# bare-command\n\n`echo hello`\n", + want: &Page{ + Title: "bare-command", + Examples: []Example{ + {Command: "echo hello"}, + }, + }, + }, + { + name: "real-world content from fixture", + content: "# test page\n\n> This is a test page.\n> More information: .\n\n- This is a description of a `command` example:\n\n`command --opt1 --opt2 {{placeholder}}`\n\n- Another one:\n\n`command --opt1 {{placeholder1 placeholder2 ...}}`\n", + want: &Page{ + Title: "test page", + Description: []string{"This is a test page."}, + URL: "https://example.org", + Examples: []Example{ + {Description: "This is a description of a `command` example", Command: "command --opt1 --opt2 {{placeholder}}"}, + {Description: "Another one", Command: "command --opt1 {{placeholder1 placeholder2 ...}}"}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Parse(tt.content) + require.NotNil(t, got) + assert.Equal(t, tt.want.Title, got.Title, "Title mismatch") + assert.Equal(t, tt.want.Description, got.Description, "Description mismatch") + assert.Equal(t, tt.want.URL, got.URL, "URL mismatch") + assert.Equal(t, tt.want.Examples, got.Examples, "Examples mismatch") + }) + } +} + +func TestParseCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + want []Segment + }{ + { + name: "no placeholders", + raw: "tar cf archive.tar", + want: []Segment{ + {Kind: Text, Text: "tar cf archive.tar"}, + }, + }, + { + name: "single placeholder", + raw: "tar cf {{archive.tar}}", + want: []Segment{ + {Kind: Text, Text: "tar cf "}, + {Kind: Placeholder, Text: "archive.tar"}, + }, + }, + { + name: "multiple placeholders", + raw: "mv {{source}} {{destination}}", + want: []Segment{ + {Kind: Text, Text: "mv "}, + {Kind: Placeholder, Text: "source"}, + {Kind: Text, Text: " "}, + {Kind: Placeholder, Text: "destination"}, + }, + }, + { + name: "option placeholder short long", + raw: "cmd {{[-s|--long]}}", + want: []Segment{ + {Kind: Text, Text: "cmd "}, + {Kind: Option, Short: "-s", Long: "--long"}, + }, + }, + { + name: "option placeholder long short reversed", + raw: "cmd {{[--long|-s]}}", + want: []Segment{ + {Kind: Text, Text: "cmd "}, + {Kind: Option, Short: "-s", Long: "--long"}, + }, + }, + { + name: "mixed text placeholder and option", + raw: "cmd {{[-s|--long]}} {{file}}", + want: []Segment{ + {Kind: Text, Text: "cmd "}, + {Kind: Option, Short: "-s", Long: "--long"}, + {Kind: Text, Text: " "}, + {Kind: Placeholder, Text: "file"}, + }, + }, + { + name: "empty string", + raw: "", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseCommand(tt.raw) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestDisplayText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + segment Segment + style config.OptionStyle + want string + }{ + { + name: "SegmentText with short style", + segment: Segment{Kind: Text, Text: "hello"}, + style: config.OptionStyleShort, + want: "hello", + }, + { + name: "SegmentText with long style", + segment: Segment{Kind: Text, Text: "hello"}, + style: config.OptionStyleLong, + want: "hello", + }, + { + name: "SegmentText with both style", + segment: Segment{Kind: Text, Text: "hello"}, + style: config.OptionStyleCombined, + want: "hello", + }, + { + name: "SegmentPlaceholder with short style", + segment: Segment{Kind: Placeholder, Text: "file"}, + style: config.OptionStyleShort, + want: "file", + }, + { + name: "SegmentPlaceholder with long style", + segment: Segment{Kind: Placeholder, Text: "file"}, + style: config.OptionStyleLong, + want: "file", + }, + { + name: "SegmentPlaceholder with both style", + segment: Segment{Kind: Placeholder, Text: "file"}, + style: config.OptionStyleCombined, + want: "file", + }, + { + name: "SegmentOption with short style", + segment: Segment{Kind: Option, Short: "-s", Long: "--long"}, + style: config.OptionStyleShort, + want: "-s", + }, + { + name: "SegmentOption with long style", + segment: Segment{Kind: Option, Short: "-s", Long: "--long"}, + style: config.OptionStyleLong, + want: "--long", + }, + { + name: "SegmentOption with both style", + segment: Segment{Kind: Option, Short: "-s", Long: "--long"}, + style: config.OptionStyleCombined, + want: "[-s|--long]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.segment.DisplayText(tt.style) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseURL(t *testing.T) { + t.Parallel() + tests := []struct { + name string + body string + want string + }{ + {name: "valid URL with trailing text", body: "More information: .", want: "https://example.org"}, + {name: "valid URL without trailing text", body: "More information: ", want: "https://example.org"}, + {name: "no URL marker", body: "Some description text.", want: ""}, + {name: "missing opening bracket", body: "More information: https://example.org>.", want: ""}, + {name: "missing closing bracket", body: "More information: .", want: ""}, + {name: "empty body", body: "", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseURL(tt.body) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestParseInnerPlaceholders(t *testing.T) { + t.Parallel() + tests := []struct { + name string + inner string + want Segment + }{ + {name: "option short long", inner: "[-s|--long]", want: Segment{Kind: Option, Short: "-s", Long: "--long"}}, + {name: "option long short reversed", inner: "[--long|-s]", want: Segment{Kind: Option, Short: "-s", Long: "--long"}}, + {name: "plain placeholder", inner: "file", want: Segment{Kind: Placeholder, Text: "file"}}, + {name: "path placeholder", inner: "path/to/file", want: Segment{Kind: Placeholder, Text: "path/to/file"}}, + {name: "empty inner", inner: "", want: Segment{Kind: Placeholder, Text: ""}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseInnerPlaceholders(tt.inner) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..5bc90d6 --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,332 @@ +package render + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/pathutil" + "github.com/TheRootDaemon/tlgc/termcolor" + "github.com/TheRootDaemon/tlgc/text" +) + +type mappedWord struct { + text string + segmentIndex int +} + +type Renderer struct { + useColor bool + w io.Writer + style config.StyleConfig + output config.OutputConfig + indent config.IndentConfig +} + +type RenderOption func(*Renderer) + +func WithColor(enabled bool) RenderOption { + return func(r *Renderer) { + r.useColor = enabled + } +} + +func WithWriter(w io.Writer) RenderOption { + return func(r *Renderer) { + r.w = w + } +} + +func WithStyle(style config.StyleConfig) RenderOption { + return func(r *Renderer) { + r.style = style + } +} + +func WithOutput(output config.OutputConfig) RenderOption { + return func(r *Renderer) { + r.output = output + } +} + +func WithIndent(indent config.IndentConfig) RenderOption { + return func(r *Renderer) { + r.indent = indent + } +} + +func New(w io.Writer, options ...RenderOption) *Renderer { + r := &Renderer{ + useColor: termcolor.SupportsColor(), + w: w, + style: config.Style(), + output: config.Output(), + indent: config.Indent(), + } + + for _, option := range options { + option(r) + } + + return r +} + +func (r *Renderer) Render(platform string, p *Page) error { + if p == nil { + return nil + } + + if r.output.RawMarkdown { + data, err := os.ReadFile(p.Path) + if err != nil { + return err + } + + _, err = r.w.Write(data) + return err + } + + if r.output.ShowTitle && p.Title != "" { + title := p.Title + if r.output.PlatformTitle && platform != "" { + title = platform + " (" + p.Title + ")" + } + + titleIndent := strings.Repeat(" ", r.indent.Title) + io.WriteString( + r.w, + r.applyStyle( + r.style.Title, + r.wrapText( + title, + titleIndent, + ), + ), + ) + io.WriteString(r.w, "\n\n") + } + + if len(p.Description) > 0 { + descIndent := strings.Repeat(" ", r.indent.Description) + for _, desc := range p.Description { + io.WriteString( + r.w, + r.applyStyle( + r.style.Description, + r.wrapText( + desc, + descIndent, + ), + ), + ) + io.WriteString(r.w, "\n") + } + io.WriteString(r.w, "\n") + } + + editURL := "" + if r.output.EditLink { + editURL = buildEditURL(p.Path, p.URL) + } + + if p.URL != "" || editURL != "" { + descIndent := strings.Repeat(" ", r.indent.Description) + + if p.URL != "" { + urlText := "More information: <" + p.URL + ">" + io.WriteString( + r.w, + r.applyStyle( + r.style.URL, + r.wrapText(urlText, descIndent), + ), + ) + io.WriteString(r.w, "\n") + } + + if editURL != "" { + io.WriteString( + r.w, + r.applyStyle( + r.style.URL, + r.wrapText("Edit: "+editURL, descIndent), + ), + ) + io.WriteString(r.w, "\n") + } + + io.WriteString(r.w, "\n") + } + + for i, ex := range p.Examples { + if i > 0 && !r.output.Compact { + io.WriteString(r.w, "\n") + } + + bulletIndent := strings.Repeat(" ", r.indent.Bullet) + desc := ex.Description + if r.output.ShowHyphens { + desc = r.output.ExamplePrefix + desc + } + + io.WriteString( + r.w, + r.applyStyle( + r.style.Bullet, + r.wrapText(desc, bulletIndent), + ), + ) + + io.WriteString(r.w, "\n") + + if ex.Command != "" { + segments := ParseCommand(ex.Command) + r.renderCommand(r.w, segments) + } + } + + return nil +} + +func (r *Renderer) applyStyle(s config.OutputStyle, t string) string { + if !r.useColor { + return t + } + + return termcolor.Sprint(styleString(s), t) +} + +func styleString(s config.OutputStyle) string { + var parts []string + if s.Bold { + parts = append(parts, "bold") + } + if s.Dim { + parts = append(parts, "dim") + } + if s.Italic { + parts = append(parts, "italic") + } + if s.Underline { + parts = append(parts, "underline") + } + if s.Strikethrough { + parts = append(parts, "strikethrough") + } + if s.Color.Kind == config.ColorKindNamed && s.Color.Named != config.ColorDefault && s.Color.Named != "" { + parts = append(parts, string(s.Color.Named)) + } + if s.Background.Kind == config.ColorKindNamed && s.Background.Named != config.ColorDefault && s.Background.Named != "" { + parts = append(parts, "on_"+string(s.Background.Named)) + } + return strings.Join(parts, " ") +} + +// wrapText wraps and indents plain text. It does not apply styling. +func (r *Renderer) wrapText(s, indent string) string { + if r.output.LineLength <= 0 || s == "" { + return indent + s + } + + wrapped := text.Wrap(s, r.output.LineLength, indent) + return indent + wrapped +} + +func (r *Renderer) renderCommand(w io.Writer, segments []Segment) { + var mappedWords []mappedWord + for i, seg := range segments { + words := strings.Fields(seg.DisplayText(r.output.OptionStyle)) + for _, word := range words { + mappedWords = append( + mappedWords, + mappedWord{text: word, segmentIndex: i}, + ) + } + } + + if len(mappedWords) == 0 { + return + } + + var b strings.Builder + for i, mw := range mappedWords { + if i > 0 { + b.WriteByte(' ') + } + b.WriteString(mw.text) + } + displayText := b.String() + + exIndent := strings.Repeat(" ", r.indent.Example) + + var wrapped string + if r.output.LineLength <= 0 { + wrapped = displayText + } else { + wrapped = text.Wrap(displayText, r.output.LineLength, exIndent) + } + + lines := strings.Split(wrapped, "\n") + wordOffset := 0 + + for _, line := range lines { + words := strings.Fields(line) + if len(words) == 0 { + continue + } + + io.WriteString(w, exIndent) + + for j, word := range words { + if wordOffset >= len(mappedWords) { + break + } + + mw := mappedWords[wordOffset] + seg := segments[mw.segmentIndex] + styled := r.applyStyle(r.styleForSegment(&seg), word) + io.WriteString(w, styled) + + if j < len(words)-1 { + io.WriteString(w, " ") + } + + wordOffset++ + } + + io.WriteString(w, "\n") + } +} + +func (r *Renderer) styleForSegment(s *Segment) config.OutputStyle { + switch s.Kind { + case Text: + return r.style.Example + case Placeholder: + return r.style.Placeholder + case Option: + return r.style.Placeholder + default: + return r.style.Example + } +} + +func buildEditURL(path, url string) string { + if url != "" { + return url + } + + if path != "" { + page := pathutil.PageName(path) + platform := pathutil.PagePlatform(path) + return fmt.Sprintf( + "https://github.com/tldr-pages/tldr/edit/main/pages/%s/%s.md", + platform, + page, + ) + } + + return "" +} From 3bf1e24a482fcf1421c0efe4822dc28b050d8363 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Tue, 30 Jun 2026 16:08:26 +0530 Subject: [PATCH 2/8] refactor(render): Simplify renderer by extracting helper functions --- internal/render/command.go | 128 ++++++++++++++++++ internal/render/render.go | 269 +++++++++++-------------------------- internal/render/style.go | 56 ++++++++ 3 files changed, 265 insertions(+), 188 deletions(-) create mode 100644 internal/render/command.go create mode 100644 internal/render/style.go diff --git a/internal/render/command.go b/internal/render/command.go new file mode 100644 index 0000000..97c5ef0 --- /dev/null +++ b/internal/render/command.go @@ -0,0 +1,128 @@ +package render + +import ( + "io" + "strings" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/text" +) + +type mappedWord struct { + text string + segmentIndex int +} + +func (r *Renderer) renderCommand(w io.Writer, segments []Segment) { + mappedWords := mapWords(segments, r.output.OptionStyle) + if len(mappedWords) == 0 { + return + } + + displayText := displayText(mappedWords) + exampleIndent := strings.Repeat(" ", r.indent.Example) + lines := wrapLines( + r.output.LineLength, + exampleIndent, + displayText, + ) + + wordOffset := 0 + + for _, line := range lines { + words := strings.Fields(line) + if len(words) == 0 { + continue + } + + r.renderCommandLine( + w, + words, + mappedWords, + segments, + exampleIndent, + &wordOffset, + ) + } +} + +func (r *Renderer) renderCommandLine( + w io.Writer, + words []string, + mappedWords []mappedWord, + segments []Segment, + indent string, + wordOffset *int, +) { + io.WriteString(w, indent) + + for j, word := range words { + if *wordOffset >= len(mappedWords) { + break + } + + mapped := mappedWords[*wordOffset] + segment := segments[mapped.segmentIndex] + + io.WriteString( + w, + r.applyStyle( + r.styleForSegment(&segment), + word, + ), + ) + + if j < len(words)-1 { + io.WriteString(w, " ") + } + + *wordOffset++ + } + + io.WriteString(w, "\n") +} + +func mapWords(segments []Segment, optionStyle config.OptionStyle) []mappedWord { + var mappedWords []mappedWord + for i, segment := range segments { + words := strings.FieldsSeq(segment.DisplayText(optionStyle)) + for word := range words { + mappedWords = append( + mappedWords, + mappedWord{ + text: word, + segmentIndex: i, + }, + ) + } + } + + return mappedWords +} + +func displayText(words []mappedWord) string { + var b strings.Builder + + for i, word := range words { + if i > 0 { + b.WriteByte(' ') + } + b.WriteString(word.text) + } + + return b.String() +} + +func wrapLines( + width int, + indent, + displayTest string, +) []string { + var wrapped string + if width <= 0 { + return []string{displayTest} + } + + wrapped = text.Wrap(displayTest, width, indent) + return strings.Split(wrapped, "\n") +} diff --git a/internal/render/render.go b/internal/render/render.go index 5bc90d6..af94ac5 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -7,16 +7,12 @@ import ( "strings" "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/logger" "github.com/TheRootDaemon/tlgc/pathutil" "github.com/TheRootDaemon/tlgc/termcolor" "github.com/TheRootDaemon/tlgc/text" ) -type mappedWord struct { - text string - segmentIndex int -} - type Renderer struct { useColor bool w io.Writer @@ -78,14 +74,14 @@ func (r *Renderer) Render(platform string, p *Page) error { return nil } - if r.output.RawMarkdown { - data, err := os.ReadFile(p.Path) - if err != nil { - return err + if r.output.EditLink { + if url := buildEditURL(p.Path, p.URL); url != "" { + r.renderEditLink(r.w, url) } + } - _, err = r.w.Write(data) - return err + if r.output.RawMarkdown { + return r.renderRaw(p) } if r.output.ShowTitle && p.Title != "" { @@ -94,222 +90,110 @@ func (r *Renderer) Render(platform string, p *Page) error { title = platform + " (" + p.Title + ")" } - titleIndent := strings.Repeat(" ", r.indent.Title) - io.WriteString( - r.w, - r.applyStyle( - r.style.Title, - r.wrapText( - title, - titleIndent, - ), - ), - ) + r.renderTitle(r.w, title) io.WriteString(r.w, "\n\n") } - if len(p.Description) > 0 { - descIndent := strings.Repeat(" ", r.indent.Description) - for _, desc := range p.Description { - io.WriteString( - r.w, - r.applyStyle( - r.style.Description, - r.wrapText( - desc, - descIndent, - ), - ), - ) - io.WriteString(r.w, "\n") - } - io.WriteString(r.w, "\n") - } - - editURL := "" - if r.output.EditLink { - editURL = buildEditURL(p.Path, p.URL) - } - - if p.URL != "" || editURL != "" { - descIndent := strings.Repeat(" ", r.indent.Description) - - if p.URL != "" { - urlText := "More information: <" + p.URL + ">" - io.WriteString( - r.w, - r.applyStyle( - r.style.URL, - r.wrapText(urlText, descIndent), - ), - ) - io.WriteString(r.w, "\n") - } - - if editURL != "" { - io.WriteString( - r.w, - r.applyStyle( - r.style.URL, - r.wrapText("Edit: "+editURL, descIndent), - ), - ) - io.WriteString(r.w, "\n") - } - - io.WriteString(r.w, "\n") - } + r.renderDescriptions(r.w, p.Description, p.URL) for i, ex := range p.Examples { if i > 0 && !r.output.Compact { io.WriteString(r.w, "\n") } - bulletIndent := strings.Repeat(" ", r.indent.Bullet) - desc := ex.Description - if r.output.ShowHyphens { - desc = r.output.ExamplePrefix + desc - } - - io.WriteString( - r.w, - r.applyStyle( - r.style.Bullet, - r.wrapText(desc, bulletIndent), - ), - ) - - io.WriteString(r.w, "\n") - - if ex.Command != "" { - segments := ParseCommand(ex.Command) - r.renderCommand(r.w, segments) - } + r.renderExample(r.w, ex) } return nil } -func (r *Renderer) applyStyle(s config.OutputStyle, t string) string { - if !r.useColor { - return t - } +func (r *Renderer) renderEditLink(w io.Writer, url string) { + logger.Info("edit this page on GitHub") + io.WriteString(w, url) - return termcolor.Sprint(styleString(s), t) + if !r.output.Compact { + io.WriteString(w, "\n") + } } -func styleString(s config.OutputStyle) string { - var parts []string - if s.Bold { - parts = append(parts, "bold") - } - if s.Dim { - parts = append(parts, "dim") - } - if s.Italic { - parts = append(parts, "italic") - } - if s.Underline { - parts = append(parts, "underline") - } - if s.Strikethrough { - parts = append(parts, "strikethrough") - } - if s.Color.Kind == config.ColorKindNamed && s.Color.Named != config.ColorDefault && s.Color.Named != "" { - parts = append(parts, string(s.Color.Named)) - } - if s.Background.Kind == config.ColorKindNamed && s.Background.Named != config.ColorDefault && s.Background.Named != "" { - parts = append(parts, "on_"+string(s.Background.Named)) +func (r *Renderer) renderRaw(p *Page) error { + data, err := os.ReadFile(p.Path) + if err != nil { + return err } - return strings.Join(parts, " ") + + _, err = r.w.Write(data) + return err } -// wrapText wraps and indents plain text. It does not apply styling. -func (r *Renderer) wrapText(s, indent string) string { - if r.output.LineLength <= 0 || s == "" { - return indent + s - } +func (r *Renderer) renderTitle(w io.Writer, title string) { + indent := strings.Repeat(" ", r.indent.Title) - wrapped := text.Wrap(s, r.output.LineLength, indent) - return indent + wrapped + io.WriteString( + w, + r.applyStyle( + r.style.Title, + r.wrapText(title, indent), + ), + ) } -func (r *Renderer) renderCommand(w io.Writer, segments []Segment) { - var mappedWords []mappedWord - for i, seg := range segments { - words := strings.Fields(seg.DisplayText(r.output.OptionStyle)) - for _, word := range words { - mappedWords = append( - mappedWords, - mappedWord{text: word, segmentIndex: i}, - ) - } - } +func (r *Renderer) renderDescriptionLine(w io.Writer, text, indent string) { + io.WriteString( + w, + r.applyStyle( + r.style.Description, + r.wrapText(text, indent), + ), + ) + io.WriteString(w, "\n") +} - if len(mappedWords) == 0 { +func (r *Renderer) renderDescriptions(w io.Writer, descs []string, url string) { + if len(descs) == 0 && url == "" { return } - var b strings.Builder - for i, mw := range mappedWords { - if i > 0 { - b.WriteByte(' ') - } - b.WriteString(mw.text) - } - displayText := b.String() + indent := strings.Repeat(" ", r.indent.Description) - exIndent := strings.Repeat(" ", r.indent.Example) - - var wrapped string - if r.output.LineLength <= 0 { - wrapped = displayText - } else { - wrapped = text.Wrap(displayText, r.output.LineLength, exIndent) + for _, d := range descs { + r.renderDescriptionLine(w, d, indent) } - lines := strings.Split(wrapped, "\n") - wordOffset := 0 - - for _, line := range lines { - words := strings.Fields(line) - if len(words) == 0 { - continue - } + if url != "" { + r.renderDescriptionLine(w, "More information: "+url+".", indent) + } - io.WriteString(w, exIndent) + io.WriteString(w, "\n") +} - for j, word := range words { - if wordOffset >= len(mappedWords) { - break - } +func (r *Renderer) renderBulletLine(w io.Writer, text, indent string) { + io.WriteString( + w, + r.applyStyle( + r.style.Bullet, + r.wrapText(text, indent), + ), + ) +} - mw := mappedWords[wordOffset] - seg := segments[mw.segmentIndex] - styled := r.applyStyle(r.styleForSegment(&seg), word) - io.WriteString(w, styled) +func (r *Renderer) renderExample(w io.Writer, ex Example) { + indent := strings.Repeat(" ", r.indent.Bullet) + desc := ex.Description - if j < len(words)-1 { - io.WriteString(w, " ") - } + if r.output.ShowHyphens { + desc = r.output.ExamplePrefix + desc + } - wordOffset++ - } + r.renderBulletLine(w, desc, indent) + if !r.output.Compact { io.WriteString(w, "\n") } -} -func (r *Renderer) styleForSegment(s *Segment) config.OutputStyle { - switch s.Kind { - case Text: - return r.style.Example - case Placeholder: - return r.style.Placeholder - case Option: - return r.style.Placeholder - default: - return r.style.Example + if ex.Command != "" { + segments := ParseCommand(ex.Command) + r.renderCommand(w, segments) } } @@ -330,3 +214,12 @@ func buildEditURL(path, url string) string { return "" } + +func (r *Renderer) wrapText(s, indent string) string { + if r.output.LineLength <= 0 || s == "" { + return indent + s + } + + wrapped := text.Wrap(s, r.output.LineLength, indent) + return indent + wrapped +} diff --git a/internal/render/style.go b/internal/render/style.go new file mode 100644 index 0000000..fe07d8a --- /dev/null +++ b/internal/render/style.go @@ -0,0 +1,56 @@ +package render + +import ( + "strings" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/TheRootDaemon/tlgc/termcolor" +) + +func (r *Renderer) applyStyle(s config.OutputStyle, t string) string { + if !r.useColor { + return t + } + + return termcolor.Sprint(styleString(s), t) +} + +func (r *Renderer) styleForSegment(s *Segment) config.OutputStyle { + switch s.Kind { + case Text: + return r.style.Example + case Placeholder: + return r.style.Placeholder + case Option: + return r.style.Placeholder + default: + return r.style.Example + } +} + +func styleString(s config.OutputStyle) string { + var parts []string + if s.Bold { + parts = append(parts, "bold") + } + if s.Dim { + parts = append(parts, "dim") + } + if s.Italic { + parts = append(parts, "italic") + } + if s.Underline { + parts = append(parts, "underline") + } + if s.Strikethrough { + parts = append(parts, "strikethrough") + } + if s.Color.Kind == config.ColorKindNamed && s.Color.Named != config.ColorDefault && s.Color.Named != "" { + parts = append(parts, string(s.Color.Named)) + } + if s.Background.Kind == config.ColorKindNamed && s.Background.Named != config.ColorDefault && s.Background.Named != "" { + parts = append(parts, "on_"+string(s.Background.Named)) + } + + return strings.Join(parts, " ") +} From 11fe2bbf19d2d926258b37e58a797d99229e41be Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Tue, 30 Jun 2026 16:38:16 +0530 Subject: [PATCH 3/8] test(render): Tests for render/style.go, add godoc at render/doc.go --- internal/render/doc.go | 7 + internal/render/style_test.go | 290 ++++++++++++++++++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 internal/render/style_test.go diff --git a/internal/render/doc.go b/internal/render/doc.go index da4cad7..9a0e8b3 100644 --- a/internal/render/doc.go +++ b/internal/render/doc.go @@ -1 +1,8 @@ +// Package render generates terminal-formatted output for tldr pages. +// +// It parses markdown-formatted tldr pages and renders them as +// colored, wrapped terminal text with syntax highlighting for command examples. +// +// Text is automatically wrapped to fit within a configurable maximum line width. +// Color output can be disabled with the WithColor(false) option. package render diff --git a/internal/render/style_test.go b/internal/render/style_test.go new file mode 100644 index 0000000..9936115 --- /dev/null +++ b/internal/render/style_test.go @@ -0,0 +1,290 @@ +package render + +import ( + "strings" + "testing" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestApplyStyle(t *testing.T) { + t.Parallel() + + someStyle := config.OutputStyle{ + Bold: true, + Color: config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorRed}, + } + + tests := []struct { + name string + useColor bool + style config.OutputStyle + input string + }{ + { + name: "useColor false returns input unchanged with empty style", + useColor: false, + style: config.OutputStyle{}, + input: "hello world", + }, + { + name: "useColor false returns input unchanged with styled style", + useColor: false, + style: someStyle, + input: "tar cf archive.tar", + }, + { + name: "useColor true returns styled output not equal to input", + useColor: true, + style: someStyle, + input: "placeholder", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + useColor: tt.useColor, + } + got := r.applyStyle(tt.style, tt.input) + + if !tt.useColor { + assert.Equal(t, tt.input, got) + } else { + assert.NotEqual(t, tt.input, got) + assert.Contains(t, got, tt.input) + assert.True(t, strings.Contains(got, "\x1b[")) + assert.True(t, strings.HasSuffix(got, "\x1b[0m")) + } + }) + } + + t.Run("useColor true with empty style returns input unchanged", func(t *testing.T) { + r := &Renderer{useColor: true} + input := "some text" + got := r.applyStyle(config.OutputStyle{}, input) + assert.Equal(t, input, got) + }) +} + +func TestStyleForSegment(t *testing.T) { + t.Parallel() + + exampleStyle := config.OutputStyle{ + Bold: true, + Color: config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorCyan}, + } + + placeholderStyle := config.OutputStyle{ + Italic: true, + Color: config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorRed}, + } + + r := &Renderer{ + style: config.StyleConfig{ + Example: exampleStyle, + Placeholder: placeholderStyle, + }, + } + + tests := []struct { + name string + segment *Segment + want config.OutputStyle + }{ + { + name: "Text segment returns Example style", + segment: &Segment{Kind: Text}, + want: exampleStyle, + }, + { + name: "Placeholder segment returns Placeholder style", + segment: &Segment{Kind: Placeholder}, + want: placeholderStyle, + }, + { + name: "Option segment returns Placeholder style", + segment: &Segment{Kind: Option, Short: "-a", Long: "--all"}, + want: placeholderStyle, + }, + { + name: "Unknown kind defaults to Example style", + segment: &Segment{Kind: Kind(99)}, + want: exampleStyle, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := r.styleForSegment(tt.segment) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestStyleString(t *testing.T) { + t.Parallel() + + red := config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorRed} + blue := config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorBlue} + def := config.DefaultColor() + + tests := []struct { + name string + style config.OutputStyle + want string + }{ + { + name: "empty style", + style: config.OutputStyle{}, + want: "", + }, + { + name: "bold only", + style: config.OutputStyle{Bold: true}, + want: "bold", + }, + { + name: "dim only", + style: config.OutputStyle{Dim: true}, + want: "dim", + }, + { + name: "italic only", + style: config.OutputStyle{Italic: true}, + want: "italic", + }, + { + name: "underline only", + style: config.OutputStyle{Underline: true}, + want: "underline", + }, + { + name: "strikethrough only", + style: config.OutputStyle{Strikethrough: true}, + want: "strikethrough", + }, + { + name: "all effects", + style: config.OutputStyle{ + Bold: true, + Dim: true, + Italic: true, + Underline: true, + Strikethrough: true, + }, + want: "bold dim italic underline strikethrough", + }, + { + name: "foreground color only", + style: config.OutputStyle{Color: red}, + want: "red", + }, + { + name: "background color only", + style: config.OutputStyle{Background: blue}, + want: "on_blue", + }, + { + name: "foreground and background combined", + style: config.OutputStyle{Color: red, Background: blue}, + want: "red on_blue", + }, + { + name: "effect with foreground and background", + style: config.OutputStyle{Bold: true, Color: red, Background: blue}, + want: "bold red on_blue", + }, + { + name: "default color excluded", + style: config.OutputStyle{Color: def}, + want: "", + }, + { + name: "default background excluded", + style: config.OutputStyle{Background: def}, + want: "", + }, + { + name: "both default color and background excluded", + style: config.OutputStyle{Color: def, Background: def}, + want: "", + }, + { + name: "default foreground but real background still included", + style: config.OutputStyle{Color: def, Background: blue}, + want: "on_blue", + }, + { + name: "default background but real foreground still included", + style: config.OutputStyle{Color: red, Background: def}, + want: "red", + }, + { + name: "default color and effects — effects still included", + style: config.OutputStyle{ + Color: def, + Bold: true, + }, + want: "bold", + }, + { + name: "empty named color excluded", + style: config.OutputStyle{Color: config.OutputColor{Kind: config.ColorKindNamed, Named: ""}}, + want: "", + }, + { + name: "zero-value color kind excluded (Kind=0 but Named empty)", + style: config.OutputStyle{Color: config.OutputColor{Kind: 0, Named: ""}}, + want: "", + }, + { + name: "256-color foreground excluded from styleString", + style: config.OutputStyle{Color: config.OutputColor{Kind: config.ColorKindColor256, Color256: 208}}, + want: "", + }, + { + name: "256-color background excluded from styleString", + style: config.OutputStyle{Background: config.OutputColor{Kind: config.ColorKindColor256, Color256: 42}}, + want: "", + }, + { + name: "RGB foreground excluded from styleString", + style: config.OutputStyle{Color: config.OutputColor{Kind: config.ColorKindRGB, RGB: [3]uint8{255, 0, 0}}}, + want: "", + }, + { + name: "RGB background excluded from styleString", + style: config.OutputStyle{Background: config.OutputColor{Kind: config.ColorKindRGB, RGB: [3]uint8{0, 255, 0}}}, + want: "", + }, + { + name: "multiple effects with colors", + style: config.OutputStyle{ + Bold: true, + Italic: true, + Underline: true, + Color: red, + Background: blue, + }, + want: "bold italic underline red on_blue", + }, + { + name: "effects with only background", + style: config.OutputStyle{ + Dim: true, + Strikethrough: true, + Background: blue, + }, + want: "dim strikethrough on_blue", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := styleString(tt.style) + assert.Equal(t, tt.want, got) + }) + } +} From 456c01d37385d22d4354c3b013a6fee2acc93211 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Tue, 30 Jun 2026 17:35:18 +0530 Subject: [PATCH 4/8] test(render): Tests for command.go, refactor to check errors --- internal/render/command.go | 63 ++++- internal/render/command_test.go | 478 ++++++++++++++++++++++++++++++++ 2 files changed, 526 insertions(+), 15 deletions(-) create mode 100644 internal/render/command_test.go diff --git a/internal/render/command.go b/internal/render/command.go index 97c5ef0..1c1c223 100644 --- a/internal/render/command.go +++ b/internal/render/command.go @@ -8,18 +8,28 @@ import ( "github.com/TheRootDaemon/tlgc/text" ) +// mappedWord pairs a single word from a command +// with the index of its originating Segment, +// so that the segment's style can be applied +// during line-by-line rendering. type mappedWord struct { text string segmentIndex int } -func (r *Renderer) renderCommand(w io.Writer, segments []Segment) { +// renderCommand writes a styled, wrapped command to w. +// +// It decomposes segments into word-level mappings, +// wraps the combined text to fit r.output.LineLength, +// and renders each wrapped line with per-word +// segment styling via renderCommandLine. +func (r *Renderer) renderCommand(w io.Writer, segments []Segment) error { mappedWords := mapWords(segments, r.output.OptionStyle) if len(mappedWords) == 0 { - return + return nil } - displayText := displayText(mappedWords) + displayText := commandText(mappedWords) exampleIndent := strings.Repeat(" ", r.indent.Example) lines := wrapLines( r.output.LineLength, @@ -35,17 +45,25 @@ func (r *Renderer) renderCommand(w io.Writer, segments []Segment) { continue } - r.renderCommandLine( + if err := r.renderCommandLine( w, words, mappedWords, segments, exampleIndent, &wordOffset, - ) + ); err != nil { + return err + } } + + return nil } +// renderCommandLine writes one indented line of a command, +// applying the style of each word's originating Segment. +// wordOffset tracks the current position in mappedWords +// across multi-line rendering. func (r *Renderer) renderCommandLine( w io.Writer, words []string, @@ -53,8 +71,11 @@ func (r *Renderer) renderCommandLine( segments []Segment, indent string, wordOffset *int, -) { - io.WriteString(w, indent) +) error { + _, err := io.WriteString(w, indent) + if err != nil { + return err + } for j, word := range words { if *wordOffset >= len(mappedWords) { @@ -64,24 +85,31 @@ func (r *Renderer) renderCommandLine( mapped := mappedWords[*wordOffset] segment := segments[mapped.segmentIndex] - io.WriteString( + if _, err := io.WriteString( w, r.applyStyle( r.styleForSegment(&segment), word, ), - ) + ); err != nil { + return err + } if j < len(words)-1 { - io.WriteString(w, " ") + _, err := io.WriteString(w, " ") + if err != nil { + return err + } } *wordOffset++ } - io.WriteString(w, "\n") + _, err = io.WriteString(w, "\n") + return err } +// mapWords flattens each Segment's DisplayText into individual words. func mapWords(segments []Segment, optionStyle config.OptionStyle) []mappedWord { var mappedWords []mappedWord for i, segment := range segments { @@ -100,7 +128,9 @@ func mapWords(segments []Segment, optionStyle config.OptionStyle) []mappedWord { return mappedWords } -func displayText(words []mappedWord) string { +// commandText joins the text fields of mapped words back +// into a single space-separated string, suitable for text wrapping. +func commandText(words []mappedWord) string { var b strings.Builder for i, word := range words { @@ -113,16 +143,19 @@ func displayText(words []mappedWord) string { return b.String() } +// wrapLines wraps displayText to fit within width columns. +// Continuation lines are prefixed with indent. +// If width ≤ 0 the text is returned as a single-element slice (no wrapping). func wrapLines( width int, indent, - displayTest string, + displayText string, ) []string { var wrapped string if width <= 0 { - return []string{displayTest} + return []string{displayText} } - wrapped = text.Wrap(displayTest, width, indent) + wrapped = text.Wrap(displayText, width, indent) return strings.Split(wrapped, "\n") } diff --git a/internal/render/command_test.go b/internal/render/command_test.go new file mode 100644 index 0000000..f6fff59 --- /dev/null +++ b/internal/render/command_test.go @@ -0,0 +1,478 @@ +package render + +import ( + "errors" + "io" + "strings" + "testing" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/stretchr/testify/assert" +) + +type errorWriter struct { + err error +} + +func (w *errorWriter) Write(p []byte) (int, error) { + return 0, w.err +} + +func TestRenderCommand(t *testing.T) { + t.Parallel() + + noColorRenderer := &Renderer{ + useColor: false, + output: config.OutputConfig{ + OptionStyle: config.OptionStyleLong, + LineLength: 0, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + + tests := []struct { + name string + renderer *Renderer + raw string + want string + }{ + { + name: "empty segments writes nothing", + renderer: noColorRenderer, + raw: "", + want: "", + }, + { + name: "simple text command", + renderer: noColorRenderer, + raw: "tar cf archive.tar", + want: " tar cf archive.tar\n", + }, + { + name: "command with placeholders", + renderer: noColorRenderer, + raw: "tar cf {{archive.tar}} {{dest}}", + want: " tar cf archive.tar dest\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + segments := ParseCommand(tt.raw) + var buf strings.Builder + err := tt.renderer.renderCommand(&buf, segments) + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } + + t.Run("wrapping produces multiple lines", func(t *testing.T) { + r := &Renderer{ + useColor: false, + output: config.OutputConfig{ + OptionStyle: config.OptionStyleLong, + LineLength: 15, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + var buf strings.Builder + err := r.renderCommand(&buf, ParseCommand("some very long command")) + assert.NoError(t, err) + assert.Equal(t, " some very long\n command\n", buf.String()) + }) + + t.Run("option rendered with short style", func(t *testing.T) { + r := &Renderer{ + useColor: false, + output: config.OutputConfig{ + OptionStyle: config.OptionStyleShort, + LineLength: 0, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + var buf strings.Builder + err := r.renderCommand(&buf, ParseCommand("cmd {{[-s|--long]}}")) + assert.NoError(t, err) + assert.Equal(t, " cmd -s\n", buf.String()) + }) + + t.Run("option rendered with combined style", func(t *testing.T) { + r := &Renderer{ + useColor: false, + output: config.OutputConfig{ + OptionStyle: config.OptionStyleCombined, + LineLength: 0, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + var buf strings.Builder + err := r.renderCommand(&buf, ParseCommand("cmd {{[-s|--long]}}")) + assert.NoError(t, err) + assert.Equal(t, " cmd [-s|--long]\n", buf.String()) + }) + + t.Run("colorized output contains ANSI sequences", func(t *testing.T) { + r := &Renderer{ + useColor: true, + style: config.DefaultStyleConfig(), + output: config.OutputConfig{ + OptionStyle: config.OptionStyleLong, + LineLength: 0, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + var buf strings.Builder + err := r.renderCommand(&buf, ParseCommand("echo hello")) + assert.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "\x1b[36m") + assert.Contains(t, output, "\x1b[0m") + assert.Contains(t, output, "echo") + assert.Contains(t, output, "hello") + }) + + t.Run("error from renderCommandLine propagates", func(t *testing.T) { + r := &Renderer{ + useColor: false, + output: config.OutputConfig{ + OptionStyle: config.OptionStyleLong, + LineLength: 0, + }, + indent: config.IndentConfig{ + Example: 4, + }, + } + err := r.renderCommand(&errorWriter{err: errors.New("write error")}, ParseCommand("echo hi")) + assert.ErrorContains(t, err, "write error") + }) +} + +func TestMapWords(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + segments []Segment + optionStyle config.OptionStyle + want []mappedWord + }{ + { + name: "empty segments", + segments: nil, + optionStyle: config.OptionStyleLong, + want: nil, + }, + { + name: "single text segment", + segments: []Segment{{Kind: Text, Text: "hello"}}, + optionStyle: config.OptionStyleLong, + want: []mappedWord{{text: "hello", segmentIndex: 0}}, + }, + { + name: "text segment with multiple words", + segments: []Segment{{Kind: Text, Text: "tar cf archive.tar"}}, + optionStyle: config.OptionStyleLong, + want: []mappedWord{ + {text: "tar", segmentIndex: 0}, + {text: "cf", segmentIndex: 0}, + {text: "archive.tar", segmentIndex: 0}, + }, + }, + { + name: "multiple text segments", + segments: []Segment{ + {Kind: Text, Text: "mv "}, + {Kind: Text, Text: "src "}, + {Kind: Text, Text: "dst"}, + }, + optionStyle: config.OptionStyleLong, + want: []mappedWord{ + {text: "mv", segmentIndex: 0}, + {text: "src", segmentIndex: 1}, + {text: "dst", segmentIndex: 2}, + }, + }, + { + name: "option with short style", + segments: []Segment{ + {Kind: Text, Text: "cmd "}, + {Kind: Option, Short: "-s", Long: "--long"}, + }, + optionStyle: config.OptionStyleShort, + want: []mappedWord{ + {text: "cmd", segmentIndex: 0}, + {text: "-s", segmentIndex: 1}, + }, + }, + { + name: "option with long style", + segments: []Segment{ + {Kind: Text, Text: "cmd "}, + {Kind: Option, Short: "-s", Long: "--long"}, + }, + optionStyle: config.OptionStyleLong, + want: []mappedWord{ + {text: "cmd", segmentIndex: 0}, + {text: "--long", segmentIndex: 1}, + }, + }, + { + name: "option with combined style", + segments: []Segment{ + {Kind: Text, Text: "cmd "}, + {Kind: Option, Short: "-s", Long: "--long"}, + }, + optionStyle: config.OptionStyleCombined, + want: []mappedWord{ + {text: "cmd", segmentIndex: 0}, + {text: "[-s|--long]", segmentIndex: 1}, + }, + }, + { + name: "placeholder segment", + segments: []Segment{ + {Kind: Text, Text: "echo "}, + {Kind: Placeholder, Text: "hello"}, + }, + optionStyle: config.OptionStyleLong, + want: []mappedWord{ + {text: "echo", segmentIndex: 0}, + {text: "hello", segmentIndex: 1}, + }, + }, + { + name: "mixed text option and placeholder", + segments: []Segment{ + {Kind: Text, Text: "cmd "}, + {Kind: Option, Short: "-o", Long: "--output"}, + {Kind: Text, Text: " "}, + {Kind: Placeholder, Text: "file"}, + }, + optionStyle: config.OptionStyleShort, + want: []mappedWord{ + {text: "cmd", segmentIndex: 0}, + {text: "-o", segmentIndex: 1}, + {text: "file", segmentIndex: 3}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mapWords(tt.segments, tt.optionStyle) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCommandText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + words []mappedWord + want string + }{ + { + name: "empty", + words: nil, + want: "", + }, + { + name: "single word", + words: []mappedWord{ + {text: "hello"}, + }, + want: "hello", + }, + { + name: "multiple words", + words: []mappedWord{ + {text: "tar"}, + {text: "cf"}, + {text: "archive.tar"}, + }, + want: "tar cf archive.tar", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := commandText(tt.words) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestWrapLines(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + width int + indent string + text string + want []string + }{ + { + name: "width zero returns single line", + width: 0, + indent: " ", + text: "some very long command that exceeds any reasonable width", + want: []string{"some very long command that exceeds any reasonable width"}, + }, + { + name: "width negative returns single line", + width: -1, + indent: " ", + text: "short", + want: []string{"short"}, + }, + { + name: "width exceeds text returns single line", + width: 100, + indent: " ", + text: "short text", + want: []string{"short text"}, + }, + { + name: "width less than text wraps with indent", + width: 5, + indent: " ", + text: "a b c d e", + want: []string{ + "a b c", + " d e", + }, + }, + { + name: "empty text returns single empty string", + width: 80, + indent: " ", + text: "", + want: []string{""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := wrapLines(tt.width, tt.indent, tt.text) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRenderCommandLine(t *testing.T) { + t.Parallel() + + r := &Renderer{ + useColor: false, + } + + tests := []struct { + name string + words []string + mappedWords []mappedWord + segments []Segment + indent string + want string + wantOffset int + writer io.Writer + wantErr string + }{ + { + name: "single word", + words: []string{"hello"}, + mappedWords: []mappedWord{ + {text: "hello", segmentIndex: 0}, + }, + segments: []Segment{ + {Kind: Text, Text: "hello"}, + }, + indent: " ", + want: " hello\n", + wantOffset: 1, + }, + { + name: "multiple words", + words: []string{"tar", "cf", "archive.tar"}, + mappedWords: []mappedWord{ + {text: "tar", segmentIndex: 0}, + {text: "cf", segmentIndex: 0}, + {text: "archive.tar", segmentIndex: 0}, + }, + segments: []Segment{ + {Kind: Text, Text: "tar cf archive.tar"}, + }, + indent: " ", + want: " tar cf archive.tar\n", + wantOffset: 3, + }, + { + name: "break when mapped words exhausted", + words: []string{"a", "b", "c"}, + mappedWords: []mappedWord{ + {text: "a", segmentIndex: 0}, + }, + segments: []Segment{ + {Kind: Text, Text: "a"}, + }, + indent: " ", + want: " a \n", + wantOffset: 1, + }, + { + name: "write error", + words: []string{"hello"}, + mappedWords: []mappedWord{ + {text: "hello", segmentIndex: 0}, + }, + segments: []Segment{ + {Kind: Text, Text: "hello"}, + }, + indent: " ", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + offset := 0 + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderCommandLine( + w, + tt.words, + tt.mappedWords, + tt.segments, + tt.indent, + &offset, + ) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.wantOffset, offset) + assert.Equal(t, tt.want, buf.String()) + }) + } +} From 60f733cfe573f724747ce28a271d92080e93d121 Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Tue, 30 Jun 2026 18:02:30 +0530 Subject: [PATCH 5/8] godoc(render): Docstrings for render --- internal/render/render.go | 138 +++++++++++++++++++++++++++++++------- 1 file changed, 113 insertions(+), 25 deletions(-) diff --git a/internal/render/render.go b/internal/render/render.go index af94ac5..918f7aa 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -13,6 +13,8 @@ import ( "github.com/TheRootDaemon/tlgc/text" ) +// Renderer renders parsed tldr pages to a writer +// with optional ANSI color and configurable styles, indentation, and output settings. type Renderer struct { useColor bool w io.Writer @@ -21,38 +23,50 @@ type Renderer struct { indent config.IndentConfig } +// RenderOption configures a Renderer. type RenderOption func(*Renderer) +// WithColor enables or disables ANSI color output. func WithColor(enabled bool) RenderOption { return func(r *Renderer) { r.useColor = enabled } } +// WithWriter sets the output writer for the Renderer. func WithWriter(w io.Writer) RenderOption { return func(r *Renderer) { r.w = w } } +// WithStyle replaces the default style configuration for all page elements. func WithStyle(style config.StyleConfig) RenderOption { return func(r *Renderer) { r.style = style } } +// WithOutput replaces the default output configuration +// (title visibility, hyphens, edit link, line length, etc.). func WithOutput(output config.OutputConfig) RenderOption { return func(r *Renderer) { r.output = output } } +// WithIndent replaces the default indentation configuration +// for each section (title, description, bullet, example). func WithIndent(indent config.IndentConfig) RenderOption { return func(r *Renderer) { r.indent = indent } } +// New creates a Renderer that writes to w. +// +// Defaults from the active config are used for style, output, and indentation; +// options may override any of these. func New(w io.Writer, options ...RenderOption) *Renderer { r := &Renderer{ useColor: termcolor.SupportsColor(), @@ -69,6 +83,9 @@ func New(w io.Writer, options ...RenderOption) *Renderer { return r } +// Render writes a formatted tldr page to the Renderer's writer. +// platform is used only when PlatformTitle is enabled. +// Nil pages are silently ignored. func (r *Renderer) Render(platform string, p *Page) error { if p == nil { return nil @@ -76,7 +93,9 @@ func (r *Renderer) Render(platform string, p *Page) error { if r.output.EditLink { if url := buildEditURL(p.Path, p.URL); url != "" { - r.renderEditLink(r.w, url) + if err := r.renderEditLink(r.w, url); err != nil { + return err + } } } @@ -90,32 +109,55 @@ func (r *Renderer) Render(platform string, p *Page) error { title = platform + " (" + p.Title + ")" } - r.renderTitle(r.w, title) - io.WriteString(r.w, "\n\n") + if err := r.renderTitle(r.w, title); err != nil { + return err + } + + _, err := io.WriteString(r.w, "\n\n") + if err != nil { + return nil + } } - r.renderDescriptions(r.w, p.Description, p.URL) + if err := r.renderDescriptions(r.w, p.Description, p.URL); err != nil { + return err + } for i, ex := range p.Examples { if i > 0 && !r.output.Compact { - io.WriteString(r.w, "\n") + _, err := io.WriteString(r.w, "\n") + if err != nil { + return err + } } - r.renderExample(r.w, ex) + if err := r.renderExample(r.w, ex); err != nil { + return err + } } return nil } -func (r *Renderer) renderEditLink(w io.Writer, url string) { +// renderEditLink writes the edit URL to w, with a trailing newline +// unless output is in compact mode. +func (r *Renderer) renderEditLink(w io.Writer, url string) error { logger.Info("edit this page on GitHub") - io.WriteString(w, url) + _, err := io.WriteString(w, url) + if err != nil { + return err + } if !r.output.Compact { - io.WriteString(w, "\n") + _, err := io.WriteString(w, "\n") + return err } + + return nil } +// renderRaw reads the raw markdown file at p.Path and +// writes it to the Renderer's writer. func (r *Renderer) renderRaw(p *Page) error { data, err := os.ReadFile(p.Path) if err != nil { @@ -126,58 +168,88 @@ func (r *Renderer) renderRaw(p *Page) error { return err } -func (r *Renderer) renderTitle(w io.Writer, title string) { +// renderTitle writes the page title, +// styled with r.style.Title and +// indented by r.indent.Title spaces. +func (r *Renderer) renderTitle(w io.Writer, title string) error { indent := strings.Repeat(" ", r.indent.Title) - io.WriteString( + _, err := io.WriteString( w, r.applyStyle( r.style.Title, r.wrapText(title, indent), ), ) + return err } -func (r *Renderer) renderDescriptionLine(w io.Writer, text, indent string) { - io.WriteString( +// renderDescriptionLine writes one description line, +// styled with r.style.Description, indented, +// and followed by a newline. +func (r *Renderer) renderDescriptionLine(w io.Writer, text, indent string) error { + _, err := io.WriteString( w, r.applyStyle( r.style.Description, r.wrapText(text, indent), ), ) - io.WriteString(w, "\n") + if err != nil { + return err + } + + _, err = io.WriteString(w, "\n") + return err } -func (r *Renderer) renderDescriptions(w io.Writer, descs []string, url string) { +// renderDescriptions writes all description lines +// followed by the "More information" URL (if set), +// each indented by r.indent.Description. +// Writes a trailing blank line after descriptions. +func (r *Renderer) renderDescriptions(w io.Writer, descs []string, url string) error { if len(descs) == 0 && url == "" { - return + return nil } indent := strings.Repeat(" ", r.indent.Description) for _, d := range descs { - r.renderDescriptionLine(w, d, indent) + if err := r.renderDescriptionLine(w, d, indent); err != nil { + return err + } } if url != "" { - r.renderDescriptionLine(w, "More information: "+url+".", indent) + if err := r.renderDescriptionLine(w, "More information: "+url+".", indent); err != nil { + return err + } } - io.WriteString(w, "\n") + _, err := io.WriteString(w, "\n") + return err } -func (r *Renderer) renderBulletLine(w io.Writer, text, indent string) { - io.WriteString( +// renderBulletLine writes one bullet item line, +// styled with r.style.Bullet and indented. +// No trailing newline is added. +func (r *Renderer) renderBulletLine(w io.Writer, text, indent string) error { + _, err := io.WriteString( w, r.applyStyle( r.style.Bullet, r.wrapText(text, indent), ), ) + + return err } -func (r *Renderer) renderExample(w io.Writer, ex Example) { +// renderExample writes one example, +// a bullet line for the description (prefixed with ExamplePrefix when ShowHyphens is set) +// followed by the styled command text on the next line. +// In compact mode the blank line between bullet and command is omitted. +func (r *Renderer) renderExample(w io.Writer, ex Example) error { indent := strings.Repeat(" ", r.indent.Bullet) desc := ex.Description @@ -185,18 +257,30 @@ func (r *Renderer) renderExample(w io.Writer, ex Example) { desc = r.output.ExamplePrefix + desc } - r.renderBulletLine(w, desc, indent) + if err := r.renderBulletLine(w, desc, indent); err != nil { + return err + } if !r.output.Compact { - io.WriteString(w, "\n") + _, err := io.WriteString(w, "\n") + if err != nil { + return err + } } if ex.Command != "" { segments := ParseCommand(ex.Command) - r.renderCommand(w, segments) + if err := r.renderCommand(w, segments); err != nil { + return err + } } + + return nil } +// buildEditURL returns the GitHub edit URL for a tldr page. +// If url is non-empty it is returned as-is (the page has a custom source). +// Otherwise the URL is constructed from the page's file path. func buildEditURL(path, url string) string { if url != "" { return url @@ -215,6 +299,10 @@ func buildEditURL(path, url string) string { return "" } +// wrapText applies text wrapping to s and prepends indent to every line. +// Wrapping is controlled by r.output.LineLength; +// when LineLength ≤ 0 or s is empty, +// only the indent is prepended without wrapping. func (r *Renderer) wrapText(s, indent string) string { if r.output.LineLength <= 0 || s == "" { return indent + s From 910fe37a2f4dbff2cc53b1ea99bcb93010c390df Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Wed, 1 Jul 2026 12:05:32 +0530 Subject: [PATCH 6/8] test(render): Tests for render.go --- internal/render/command_test.go | 8 - internal/render/main_test.go | 11 + internal/render/render_test.go | 914 ++++++++++++++++++++++++++++++++ 3 files changed, 925 insertions(+), 8 deletions(-) create mode 100644 internal/render/main_test.go create mode 100644 internal/render/render_test.go diff --git a/internal/render/command_test.go b/internal/render/command_test.go index f6fff59..e1e6e99 100644 --- a/internal/render/command_test.go +++ b/internal/render/command_test.go @@ -10,14 +10,6 @@ import ( "github.com/stretchr/testify/assert" ) -type errorWriter struct { - err error -} - -func (w *errorWriter) Write(p []byte) (int, error) { - return 0, w.err -} - func TestRenderCommand(t *testing.T) { t.Parallel() diff --git a/internal/render/main_test.go b/internal/render/main_test.go new file mode 100644 index 0000000..286d5bb --- /dev/null +++ b/internal/render/main_test.go @@ -0,0 +1,11 @@ +package render + +// errorWriter is a test stub that always returns the configured write error. +type errorWriter struct { + err error +} + +// Write always returns the configured error. +func (w *errorWriter) Write(p []byte) (int, error) { + return 0, w.err +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..4dda862 --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,914 @@ +package render + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/TheRootDaemon/tlgc/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithColor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts []RenderOption + want bool + }{ + {name: "overrides to false", opts: []RenderOption{WithColor(false)}, want: false}, + {name: "overrides to true", opts: []RenderOption{WithColor(true)}, want: true}, + {name: "last option wins", opts: []RenderOption{WithColor(false), WithColor(true)}, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + r := New(&buf, tt.opts...) + assert.Equal(t, tt.want, r.useColor) + }) + } +} + +func TestWithWriter(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + writer io.Writer + }{ + {name: "os.Stdout", writer: os.Stdout}, + {name: "nil writer", writer: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := New(&strings.Builder{}, WithWriter(tt.writer)) + assert.Equal(t, tt.writer, r.w) + }) + } +} + +func TestWithStyle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + style config.StyleConfig + }{ + { + name: "custom style", + style: config.StyleConfig{ + Title: config.OutputStyle{ + Bold: true, + Color: config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorRed}, + }, + }, + }, + { + name: "zero value style", + style: config.StyleConfig{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + r := New(&buf, WithStyle(tt.style)) + assert.Equal(t, tt.style, r.style) + }) + } +} + +func TestWithOutput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + output config.OutputConfig + }{ + { + name: "custom output", + output: config.OutputConfig{ + ShowTitle: false, + LineLength: 50, + Compact: true, + }, + }, + { + name: "zero value output", + output: config.OutputConfig{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + r := New(&buf, WithOutput(tt.output)) + assert.Equal(t, tt.output, r.output) + }) + } +} + +func TestWithIndent(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + indent config.IndentConfig + }{ + { + name: "custom indent", + indent: config.IndentConfig{ + Title: 0, + Description: 1, + Bullet: 2, + Example: 3, + }, + }, + { + name: "zero value indent", + indent: config.IndentConfig{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + r := New(&buf, WithIndent(tt.indent)) + assert.Equal(t, tt.indent, r.indent) + }) + } +} + +func TestNew(t *testing.T) { + t.Run("defaults with no config loaded", func(t *testing.T) { + config.ResetForTesting() + t.Cleanup(config.ResetForTesting) + t.Setenv("NO_COLOR", "1") + + var buf strings.Builder + r := New(&buf) + + assert.False(t, r.useColor) + assert.Equal(t, &buf, r.w) + assert.Equal(t, config.DefaultStyleConfig(), r.style) + assert.Equal(t, config.DefaultOutputConfig(), r.output) + assert.Equal(t, config.DefaultIndentConfig(), r.indent) + }) + + t.Run("picks up custom config", func(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + content := ` +[output] +show_title = false +line_length = 60 + +[indent] +title = 4 +example = 6 +` + require.NoError(t, os.WriteFile(cfgPath, []byte(content), 0o644)) + t.Setenv("TLGC_CONFIG", cfgPath) + t.Setenv("NO_COLOR", "1") + config.ResetForTesting() + require.NoError(t, config.Initialize()) + t.Cleanup(config.ResetForTesting) + + var buf strings.Builder + r := New(&buf) + + assert.Equal(t, 4, r.indent.Title) + assert.Equal(t, 6, r.indent.Example) + assert.Equal(t, 2, r.indent.Description) + assert.Equal(t, 2, r.indent.Bullet) + assert.False(t, r.output.ShowTitle) + assert.Equal(t, 60, r.output.LineLength) + assert.False(t, r.output.ShowHyphens) + }) + + t.Run("color disabled when TERM is dumb", func(t *testing.T) { + t.Setenv("TERM", "dumb") + + var buf strings.Builder + r := New(&buf) + + assert.False(t, r.useColor) + }) +} + +func TestRender(t *testing.T) { + fullPageWant := " tar\n\n" + + " archive utility.\n" + + " More information: https://example.org.\n" + + "\n" + + " create archive\n" + + " tar cf archive.tar\n" + + "\n" + + " extract\n" + + " tar xf archive.tar\n" + + tests := []struct { + name string + platform string + renderer *Renderer + page *Page + want string + contains []string + notContains []string + wantErr string + }{ + { + name: "nil page returns nil", + renderer: &Renderer{}, + want: "", + }, + { + name: "empty page with only title", + renderer: &Renderer{output: config.OutputConfig{ShowTitle: true}, indent: config.IndentConfig{Title: 2}}, + page: &Page{Title: "tar"}, + want: " tar\n\n", + }, + { + name: "full page renders all sections in order", + renderer: &Renderer{ + output: config.OutputConfig{ShowTitle: true}, + indent: config.IndentConfig{Title: 2, Description: 2, Bullet: 2, Example: 4}, + }, + page: &Page{ + Title: "tar", + Description: []string{"archive utility."}, + URL: "https://example.org", + Examples: []Example{ + {Description: "create archive", Command: "tar cf archive.tar"}, + {Description: "extract", Command: "tar xf archive.tar"}, + }, + }, + want: fullPageWant, + }, + { + name: "platform title includes platform prefix", + platform: "linux", + renderer: &Renderer{ + output: config.OutputConfig{ShowTitle: true, PlatformTitle: true}, + indent: config.IndentConfig{Title: 2}, + }, + page: &Page{Title: "tar"}, + want: " linux (tar)\n\n", + }, + { + name: "edit link rendered before title", + renderer: &Renderer{ + output: config.OutputConfig{EditLink: true, ShowTitle: true}, + indent: config.IndentConfig{Title: 2}, + }, + page: &Page{ + Path: "/pages/common/tar.md", + Title: "tar", + }, + contains: []string{ + "https://github.com/tldr-pages/tldr/edit/main/pages/common/tar.md\n", + " tar\n\n", + }, + }, + { + name: "raw markdown mode writes file content", + renderer: &Renderer{output: config.OutputConfig{RawMarkdown: true}}, + page: &Page{}, + want: "# test page\n\n> description.\n", + }, + { + name: "compact mode omits blank lines between examples", + renderer: &Renderer{ + output: config.OutputConfig{Compact: true}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + }, + page: &Page{ + Examples: []Example{ + {Description: "first", Command: "echo a"}, + {Description: "second", Command: "echo b"}, + }, + }, + notContains: []string{"\n\n"}, + }, + { + name: "title hidden when ShowTitle is false", + renderer: &Renderer{ + output: config.OutputConfig{ShowTitle: false}, + indent: config.IndentConfig{Title: 2, Description: 2, Bullet: 2, Example: 4}, + }, + page: &Page{ + Title: "tar", + Description: []string{"desc."}, + Examples: []Example{{Description: "ex", Command: "cmd"}}, + }, + notContains: []string{"tar"}, + }, + { + name: "write error propagates", + renderer: &Renderer{ + w: &errorWriter{err: errors.New("write error")}, + output: config.OutputConfig{ShowTitle: true}, + indent: config.IndentConfig{Title: 2}, + }, + page: &Page{Title: "tar"}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf strings.Builder + if tt.renderer.w == nil { + tt.renderer.w = &buf + } + + if tt.renderer.output.RawMarkdown && tt.want != "" { + path := filepath.Join(t.TempDir(), "page.md") + require.NoError(t, os.WriteFile(path, []byte(tt.want), 0o644)) + tt.page.Path = path + } + + err := tt.renderer.Render(tt.platform, tt.page) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + got := buf.String() + + if tt.want != "" { + assert.Equal(t, tt.want, got) + } + for _, s := range tt.contains { + assert.Contains(t, got, s) + } + for _, s := range tt.notContains { + assert.NotContains(t, got, s) + } + }) + } +} + +func TestRenderEditLink(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + compact bool + url string + writer io.Writer + want string + wantErr string + }{ + { + name: "non-compact adds newline", + compact: false, + url: "https://example.com", + want: "https://example.com\n", + }, + { + name: "compact omits newline", + compact: true, + url: "https://example.com", + want: "https://example.com", + }, + { + name: "write error", + compact: false, + url: "url", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + output: config.OutputConfig{Compact: tt.compact}, + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderEditLink(w, tt.url) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestRenderRaw(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + writer io.Writer + want string + wantErr string + }{ + { + name: "writes file content", + content: "# hello", + want: "# hello", + }, + { + name: "file not found", + wantErr: "no such file", + }, + { + name: "write error", + content: "data", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := "/nonexistent/file.md" + if tt.content != "" { + path = filepath.Join(t.TempDir(), "page.md") + require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644)) + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + r := &Renderer{w: w} + err := r.renderRaw(&Page{Path: path}) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestRenderTitle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + title string + indent int + useColor bool + style config.OutputStyle + writer io.Writer + want string + wantErr string + }{ + { + name: "title with indent", + title: "tar", + indent: 2, + want: " tar", + }, + { + name: "zero indent", + title: "tar", + indent: 0, + want: "tar", + }, + { + name: "colorized title", + title: "tar", + indent: 2, + useColor: true, + style: config.OutputStyle{ + Bold: true, + Color: config.OutputColor{Kind: config.ColorKindNamed, Named: config.ColorRed}, + }, + }, + { + name: "write error", + title: "tar", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + useColor: tt.useColor, + style: config.StyleConfig{Title: tt.style}, + indent: config.IndentConfig{Title: tt.indent}, + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderTitle(w, tt.title) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + got := buf.String() + + if tt.useColor { + assert.Contains(t, got, " tar") + assert.Contains(t, got, "\x1b[") + assert.True(t, strings.HasSuffix(got, "\x1b[0m")) + } else { + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestRenderDescriptionLine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + indent string + writer io.Writer + want string + wantErr string + }{ + { + name: "writes text with trailing newline", + text: "hello", + indent: " ", + want: " hello\n", + }, + { + name: "write error", + text: "hello", + indent: " ", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{} + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderDescriptionLine(w, tt.text, tt.indent) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestRenderDescriptions(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + descs []string + url string + writer io.Writer + want string + wantErr string + }{ + { + name: "no descriptions and no url returns nil", + descs: nil, + url: "", + want: "", + }, + { + name: "single description", + descs: []string{"hello"}, + want: " hello\n\n", + }, + { + name: "multiple descriptions", + descs: []string{"first", "second"}, + want: " first\n second\n\n", + }, + { + name: "description with URL", + descs: []string{"hello"}, + url: "https://example.org", + want: " hello\n More information: https://example.org.\n\n", + }, + { + name: "URL only no descriptions", + url: "https://example.org", + want: " More information: https://example.org.\n\n", + }, + { + name: "write error", + descs: []string{"hello"}, + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + indent: config.IndentConfig{Description: 2}, + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderDescriptions(w, tt.descs, tt.url) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestRenderBulletLine(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + text string + indent string + writer io.Writer + want string + wantErr string + }{ + { + name: "writes text without trailing newline", + text: "hello", + indent: " ", + want: " hello", + }, + { + name: "write error", + text: "hello", + indent: " ", + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{} + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderBulletLine(w, tt.text, tt.indent) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestRenderExample(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ex Example + indent config.IndentConfig + output config.OutputConfig + writer io.Writer + want string + wantErr string + }{ + { + name: "description and command non-compact", + ex: Example{Description: "create archive", Command: "tar cf archive.tar"}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + want: " create archive\n tar cf archive.tar\n", + }, + { + name: "description only no command", + ex: Example{Description: "just a description"}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + want: " just a description\n", + }, + { + name: "hyphens enabled", + ex: Example{Description: "create archive", Command: "tar cf archive.tar"}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + output: config.OutputConfig{ShowHyphens: true, ExamplePrefix: "- "}, + want: " - create archive\n tar cf archive.tar\n", + }, + { + name: "compact mode no blank line", + ex: Example{Description: "create archive", Command: "tar cf archive.tar"}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + output: config.OutputConfig{Compact: true}, + want: " create archive tar cf archive.tar\n", + }, + { + name: "write error", + ex: Example{Description: "error"}, + indent: config.IndentConfig{Bullet: 2, Example: 4}, + writer: &errorWriter{err: errors.New("write error")}, + wantErr: "write error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + useColor: false, + output: tt.output, + indent: tt.indent, + } + + var buf strings.Builder + w := io.Writer(&buf) + if tt.writer != nil { + w = tt.writer + } + + err := r.renderExample(w, tt.ex) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, buf.String()) + }) + } +} + +func TestBuildEditURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + url string + want string + }{ + { + name: "non-empty url returned as-is", + path: "/pages/common/tar.md", + url: "https://example.com", + want: "https://example.com", + }, + { + name: "empty url constructs from path", + path: "/pages/common/tar.md", + want: "https://github.com/tldr-pages/tldr/edit/main/pages/common/tar.md", + }, + { + name: "linux platform extracted correctly", + path: "/pages/linux/apt.md", + want: "https://github.com/tldr-pages/tldr/edit/main/pages/linux/apt.md", + }, + { + name: "windows platform extracted correctly", + path: "/pages/windows/dir.md", + want: "https://github.com/tldr-pages/tldr/edit/main/pages/windows/dir.md", + }, + { + name: "empty path and empty url returns empty", + path: "", + url: "", + want: "", + }, + { + name: "path without .md extension adds .md", + path: "/pages/common/some-page", + want: "https://github.com/tldr-pages/tldr/edit/main/pages/common/some-page.md", + }, + { + name: "url takes precedence over path", + path: "/pages/common/tar.md", + url: "https://custom.com/edit", + want: "https://custom.com/edit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildEditURL(tt.path, tt.url) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestWrapText(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + lineLength int + s string + indent string + want string + }{ + { + name: "line length zero returns indent plus text", + lineLength: 0, + s: "hello world", + indent: " ", + want: " hello world", + }, + { + name: "line length negative returns indent plus text", + lineLength: -1, + s: "short", + indent: " ", + want: " short", + }, + { + name: "empty text returns indent", + lineLength: 80, + s: "", + indent: ">>", + want: ">>", + }, + { + name: "text fits within line length", + lineLength: 80, + s: "hi", + indent: " ", + want: " hi", + }, + { + name: "text wraps with indent on continuation lines", + lineLength: 12, + s: "hello world foo", + indent: "> ", + want: "> hello world\n> foo", + }, + { + name: "long word exceeds line length without splitting", + lineLength: 5, + s: "superlongword", + indent: "- ", + want: "- superlongword", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Renderer{ + output: config.OutputConfig{LineLength: tt.lineLength}, + } + got := r.wrapText(tt.s, tt.indent) + assert.Equal(t, tt.want, got) + }) + } +} From 9d64df04379dfb2e66dad29c95a7f227ae4100de Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Wed, 1 Jul 2026 12:11:10 +0530 Subject: [PATCH 7/8] godoc(render): Docstrings for render, render/style, render/page --- internal/render/page.go | 59 ++++++++++++++++++++++++++++++++++++++- internal/render/render.go | 10 +++---- internal/render/style.go | 10 +++++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/internal/render/page.go b/internal/render/page.go index 37c0fb1..f09e13f 100644 --- a/internal/render/page.go +++ b/internal/render/page.go @@ -8,18 +8,33 @@ import ( ) var ( + // placeholderPattern matches tldr placeholder tokens like {{archive.tar}} + // and captures the inner content (the text between the braces). placeholderPattern = regexp.MustCompile(`\{\{(.*?)\}\}`) - optionPattern = regexp.MustCompile(`^\[(.+)\|(.+)\]$`) + + // optionPattern matches the option syntax embedded inside a placeholder, + // e.g. [-s|--long]. It captures the short form and the long form. + optionPattern = regexp.MustCompile(`^\[(.+)\|(.+)\]$`) ) +// Kind represents the type of a parsed command segment. type Kind int const ( + // Text is a plain literal segment that should be rendered as-is. Text Kind = iota + + // Placeholder is a user-supplied value wrapped in {{...}}. Placeholder + + // Option is a command-line flag embedded in {{[...|...]}} syntax. Option ) +// Segment holds a parsed piece of a command string. +// A segment is either literal text, +// a user-supplied placeholder value, +// or a command-line option with short and long forms. type Segment struct { Kind Kind Text string @@ -27,11 +42,19 @@ type Segment struct { Long string } +// Example is a single tldr example +// consisting of a description +// and the associated command text. type Example struct { Description string Command string } +// Page is a parsed tldr page containing a title, +// zero or more description lines, +// an optional "More information" URL, +// the filesystem path to the source file, +// and a list of examples. type Page struct { Title string URL string @@ -40,6 +63,15 @@ type Page struct { Examples []Example } +// Parse parses a raw markdown tldr page string into a Page. +// +// It recognises the following markdown structure: +// - # Title +// - > Description lines (with optional "More information: " extraction) +// - - Example descriptions (optionally ending with a colon) +// - `command` lines (associated with the preceding example) +// +// Nil and empty content produce a Page with zero-valued fields. func Parse(content string) *Page { p := &Page{} lines := strings.SplitSeq(content, "\n") @@ -88,6 +120,12 @@ func Parse(content string) *Page { return p } +// ParseCommand splits a raw command string into a slice of Segments. +// +// Placeholder tokens wrapped in {{...}} are extracted +// as either Placeholder or Option segments depending on their inner content. +// Text outside of placeholders becomes Text segments. +// An empty string returns nil. func ParseCommand(raw string) []Segment { if raw == "" { return nil @@ -144,6 +182,16 @@ func ParseCommand(raw string) []Segment { return segments } +// DisplayText returns the text +// that should be displayed for the segment, +// taking the option display style into account. +// +// For Option segments the return value depends on style: +// - OptionStyleShort returns the short form (e.g. "-s") +// - OptionStyleLong returns the long form (e.g. "--long") +// - OptionStyleCombined returns both (e.g. "[-s|--long]") +// +// All other kinds return the Text field unchanged. func (s Segment) DisplayText(style config.OptionStyle) string { switch s.Kind { case Option: @@ -162,6 +210,9 @@ func (s Segment) DisplayText(style config.OptionStyle) string { } } +// parseURL extracts the URL from a "More information" description line. +// It expects the URL to be wrapped in angle brackets like . +// Returns the empty string if no valid URL is found. func parseURL(body string) string { if !strings.Contains(body, "More information: <") { return "" @@ -176,6 +227,12 @@ func parseURL(body string) string { return "" } +// parseInnerPlaceholders parses the inner content of a {{...}} placeholder. +// +// If the inner content matches the option pattern [short|long], +// an Option segment is returned with the short and long forms correctly +// identified regardless of their order. +// Otherwise a plain Placeholder segment is returned. func parseInnerPlaceholders(inner string) Segment { if options := optionPattern.FindStringSubmatch(inner); len(options) == 3 { left, right := options[1], options[2] diff --git a/internal/render/render.go b/internal/render/render.go index 918f7aa..6b5a0b6 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -16,11 +16,11 @@ import ( // Renderer renders parsed tldr pages to a writer // with optional ANSI color and configurable styles, indentation, and output settings. type Renderer struct { - useColor bool - w io.Writer - style config.StyleConfig - output config.OutputConfig - indent config.IndentConfig + useColor bool // whether ANSI color output is enabled + w io.Writer // destination for rendered output + style config.StyleConfig // style configuration for each page element + output config.OutputConfig // output visibility and formatting options + indent config.IndentConfig // indentation per section } // RenderOption configures a Renderer. diff --git a/internal/render/style.go b/internal/render/style.go index fe07d8a..6865906 100644 --- a/internal/render/style.go +++ b/internal/render/style.go @@ -7,6 +7,8 @@ import ( "github.com/TheRootDaemon/tlgc/termcolor" ) +// applyStyle applies the ANSI styling defined by s to the text t. +// When the renderer's useColor is false, t is returned unchanged. func (r *Renderer) applyStyle(s config.OutputStyle, t string) string { if !r.useColor { return t @@ -15,6 +17,10 @@ func (r *Renderer) applyStyle(s config.OutputStyle, t string) string { return termcolor.Sprint(styleString(s), t) } +// styleForSegment returns the OutputStyle +// that should be used for a given Segment kind. +// Text segments use the Example style; +// Placeholder and Option segments use the Placeholder style. func (r *Renderer) styleForSegment(s *Segment) config.OutputStyle { switch s.Kind { case Text: @@ -28,6 +34,10 @@ func (r *Renderer) styleForSegment(s *Segment) config.OutputStyle { } } +// styleString converts an OutputStyle into a space-separated string +// of style directives (e.g. "bold red on_blue") suitable for termcolor.Sprint. +// Named foreground and background colors are included +// only when they differ from the default and are non-empty. func styleString(s config.OutputStyle) string { var parts []string if s.Bold { From f2fb5da46888c31bad7e139369b7b75137ebe1fa Mon Sep 17 00:00:00 2001 From: TheRootDaemon Date: Wed, 1 Jul 2026 12:21:35 +0530 Subject: [PATCH 8/8] test(render): Refactor flaky tests --- internal/render/render_test.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 4dda862..8f9caf4 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -419,11 +419,12 @@ func TestRenderRaw(t *testing.T) { t.Parallel() tests := []struct { - name string - content string - writer io.Writer - want string - wantErr string + name string + content string + writer io.Writer + want string + wantErr string + wantAnyErr bool }{ { name: "writes file content", @@ -431,8 +432,8 @@ func TestRenderRaw(t *testing.T) { want: "# hello", }, { - name: "file not found", - wantErr: "no such file", + name: "file not found", + wantAnyErr: true, }, { name: "write error", @@ -463,6 +464,10 @@ func TestRenderRaw(t *testing.T) { assert.ErrorContains(t, err, tt.wantErr) return } + if tt.wantAnyErr { + assert.Error(t, err) + return + } assert.NoError(t, err) assert.Equal(t, tt.want, buf.String())