Skip to content

Commit f5ecbd2

Browse files
committed
implement GitHub Docs search and CLI
Hit the /api/search/v1 endpoint with client_name=docs to get clean JSON directly, avoiding the /search redirect to the HTML page. Config carries BaseURL, Rate, Retries, Timeout, UserAgent. Search returns Article{Title, Breadcrumb, URL, Excerpt} with <mark> tags stripped. CLI: ghdocs search <query> with full table/jsonl/csv/url output modes and the standard --limit, --fields, --template, --no-header flags.
1 parent 02269cd commit f5ecbd2

8 files changed

Lines changed: 802 additions & 54 deletions

File tree

‎cli/cmd_search.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package cli
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
func (a *App) searchCmd() *cobra.Command {
8+
cmd := &cobra.Command{
9+
Use: "search <query>",
10+
Short: "Search GitHub Docs",
11+
Long: `Search GitHub Docs for articles matching the query.
12+
13+
Results include the article title, breadcrumb navigation path, URL,
14+
and a short excerpt from the matching content.`,
15+
Args: cobra.ExactArgs(1),
16+
RunE: func(cmd *cobra.Command, args []string) error {
17+
n := a.effectiveLimit(10)
18+
a.progressf("searching GitHub Docs for %q...", args[0])
19+
articles, err := a.client.Search(cmd.Context(), args[0], n)
20+
if err != nil {
21+
return mapFetchErr(err)
22+
}
23+
return a.renderOrEmpty(articles, len(articles))
24+
},
25+
}
26+
return cmd
27+
}

‎cli/errors.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package cli
2+
3+
import (
4+
"errors"
5+
6+
"github.com/tamnd/githubdocs-cli/githubdocs"
7+
)
8+
9+
func isNotFound(err error) bool {
10+
return errors.Is(err, githubdocs.ErrNotFound)
11+
}

‎cli/output.go‎

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
package cli
2+
3+
import (
4+
"encoding/csv"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"reflect"
9+
"strconv"
10+
"strings"
11+
"text/tabwriter"
12+
"text/template"
13+
"time"
14+
)
15+
16+
// Format is an output rendering format.
17+
type Format string
18+
19+
const (
20+
FormatTable Format = "table"
21+
FormatJSON Format = "json"
22+
FormatJSONL Format = "jsonl"
23+
FormatCSV Format = "csv"
24+
FormatTSV Format = "tsv"
25+
FormatURL Format = "url"
26+
FormatRaw Format = "raw"
27+
)
28+
29+
// Valid reports whether f is one of the supported formats.
30+
func (f Format) Valid() bool {
31+
switch f {
32+
case FormatTable, FormatJSON, FormatJSONL, FormatCSV, FormatTSV, FormatURL, FormatRaw:
33+
return true
34+
}
35+
return false
36+
}
37+
38+
// Renderer turns slices of records into one of the supported output formats.
39+
type Renderer struct {
40+
Format Format
41+
Fields []string
42+
NoHeader bool
43+
Template string
44+
w io.Writer
45+
}
46+
47+
// NewRenderer builds a Renderer writing to w.
48+
func NewRenderer(w io.Writer, format Format, fields []string, noHeader bool, tmpl string) *Renderer {
49+
return &Renderer{Format: format, Fields: fields, NoHeader: noHeader, Template: tmpl, w: w}
50+
}
51+
52+
// Render writes records in the configured format.
53+
func (r *Renderer) Render(records any) error {
54+
rv := reflect.ValueOf(records)
55+
if rv.Kind() == reflect.Pointer {
56+
rv = rv.Elem()
57+
}
58+
if rv.Kind() != reflect.Slice {
59+
s := reflect.MakeSlice(reflect.SliceOf(rv.Type()), 1, 1)
60+
s.Index(0).Set(rv)
61+
rv = s
62+
}
63+
n := rv.Len()
64+
items := make([]any, n)
65+
for i := 0; i < n; i++ {
66+
items[i] = rv.Index(i).Interface()
67+
}
68+
69+
if r.Template != "" {
70+
return r.renderTemplate(items)
71+
}
72+
switch r.Format {
73+
case FormatJSON:
74+
return r.renderJSON(items)
75+
case FormatJSONL:
76+
return r.renderJSONL(items)
77+
case FormatCSV:
78+
return r.renderDelimited(items, ',')
79+
case FormatTSV:
80+
return r.renderDelimited(items, '\t')
81+
case FormatURL:
82+
return r.renderURL(items)
83+
case FormatRaw:
84+
return r.renderRaw(items)
85+
default:
86+
return r.renderTable(items)
87+
}
88+
}
89+
90+
func (r *Renderer) renderJSON(items []any) error {
91+
enc := json.NewEncoder(r.w)
92+
enc.SetIndent("", " ")
93+
if len(items) == 1 {
94+
return enc.Encode(items[0])
95+
}
96+
return enc.Encode(items)
97+
}
98+
99+
func (r *Renderer) renderJSONL(items []any) error {
100+
enc := json.NewEncoder(r.w)
101+
for _, it := range items {
102+
if err := enc.Encode(it); err != nil {
103+
return err
104+
}
105+
}
106+
return nil
107+
}
108+
109+
func (r *Renderer) renderTemplate(items []any) error {
110+
t, err := template.New("row").Funcs(template.FuncMap{
111+
"join": func(sep string, ss []string) string { return strings.Join(ss, sep) },
112+
}).Parse(r.Template)
113+
if err != nil {
114+
return fmt.Errorf("parse --template: %w", err)
115+
}
116+
for _, it := range items {
117+
if err := t.Execute(r.w, it); err != nil {
118+
return err
119+
}
120+
_, _ = fmt.Fprintln(r.w)
121+
}
122+
return nil
123+
}
124+
125+
func (r *Renderer) renderURL(items []any) error {
126+
for _, it := range items {
127+
m := toMap(it)
128+
if u, ok := m["url"]; ok && u != "" {
129+
_, _ = fmt.Fprintln(r.w, u)
130+
}
131+
}
132+
return nil
133+
}
134+
135+
func (r *Renderer) renderRaw(items []any) error {
136+
cols := r.columns(items)
137+
for _, it := range items {
138+
m := toMap(it)
139+
vals := make([]string, 0, len(cols))
140+
for _, c := range cols {
141+
vals = append(vals, m[c])
142+
}
143+
_, _ = fmt.Fprintln(r.w, strings.Join(vals, " "))
144+
}
145+
return nil
146+
}
147+
148+
func (r *Renderer) renderTable(items []any) error {
149+
if len(items) == 0 {
150+
return nil
151+
}
152+
cols := r.columns(items)
153+
tw := tabwriter.NewWriter(r.w, 0, 4, 2, ' ', 0)
154+
if !r.NoHeader {
155+
_, _ = fmt.Fprintln(tw, strings.Join(upperAll(cols), "\t"))
156+
}
157+
for _, it := range items {
158+
m := toMap(it)
159+
cells := make([]string, len(cols))
160+
for i, c := range cols {
161+
cells[i] = truncate(m[c], 60)
162+
}
163+
_, _ = fmt.Fprintln(tw, strings.Join(cells, "\t"))
164+
}
165+
return tw.Flush()
166+
}
167+
168+
func (r *Renderer) renderDelimited(items []any, comma rune) error {
169+
if len(items) == 0 {
170+
return nil
171+
}
172+
cols := r.columns(items)
173+
cw := csv.NewWriter(r.w)
174+
cw.Comma = comma
175+
if !r.NoHeader {
176+
if err := cw.Write(cols); err != nil {
177+
return err
178+
}
179+
}
180+
for _, it := range items {
181+
m := toMap(it)
182+
row := make([]string, len(cols))
183+
for i, c := range cols {
184+
row[i] = m[c]
185+
}
186+
if err := cw.Write(row); err != nil {
187+
return err
188+
}
189+
}
190+
cw.Flush()
191+
return cw.Error()
192+
}
193+
194+
func (r *Renderer) columns(items []any) []string {
195+
if len(r.Fields) > 0 {
196+
return r.Fields
197+
}
198+
if len(items) == 0 {
199+
return nil
200+
}
201+
return structJSONKeys(items[0])
202+
}
203+
204+
func toMap(v any) map[string]string {
205+
out := map[string]string{}
206+
rv := reflect.ValueOf(v)
207+
if rv.Kind() == reflect.Pointer {
208+
rv = rv.Elem()
209+
}
210+
if rv.Kind() != reflect.Struct {
211+
return out
212+
}
213+
rt := rv.Type()
214+
for i := 0; i < rt.NumField(); i++ {
215+
f := rt.Field(i)
216+
if f.PkgPath != "" {
217+
continue
218+
}
219+
key := jsonKey(f)
220+
if key == "-" {
221+
continue
222+
}
223+
out[key] = formatValue(rv.Field(i))
224+
}
225+
return out
226+
}
227+
228+
func structJSONKeys(v any) []string {
229+
rv := reflect.ValueOf(v)
230+
if rv.Kind() == reflect.Pointer {
231+
rv = rv.Elem()
232+
}
233+
if rv.Kind() != reflect.Struct {
234+
return nil
235+
}
236+
rt := rv.Type()
237+
var keys []string
238+
for i := 0; i < rt.NumField(); i++ {
239+
f := rt.Field(i)
240+
if f.PkgPath != "" {
241+
continue
242+
}
243+
key := jsonKey(f)
244+
if key == "-" {
245+
continue
246+
}
247+
keys = append(keys, key)
248+
}
249+
return keys
250+
}
251+
252+
func jsonKey(f reflect.StructField) string {
253+
tag := f.Tag.Get("json")
254+
if tag == "" {
255+
return f.Name
256+
}
257+
name := strings.Split(tag, ",")[0]
258+
if name == "" {
259+
return f.Name
260+
}
261+
return name
262+
}
263+
264+
func formatValue(v reflect.Value) string {
265+
switch v.Kind() {
266+
case reflect.String:
267+
return v.String()
268+
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
269+
return strconv.FormatInt(v.Int(), 10)
270+
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
271+
return strconv.FormatUint(v.Uint(), 10)
272+
case reflect.Float32, reflect.Float64:
273+
return strconv.FormatFloat(v.Float(), 'g', -1, 64)
274+
case reflect.Bool:
275+
return strconv.FormatBool(v.Bool())
276+
case reflect.Slice:
277+
parts := make([]string, v.Len())
278+
for i := 0; i < v.Len(); i++ {
279+
parts[i] = formatValue(v.Index(i))
280+
}
281+
return strings.Join(parts, ";")
282+
case reflect.Struct:
283+
if t, ok := v.Interface().(time.Time); ok {
284+
if t.IsZero() {
285+
return ""
286+
}
287+
return t.Format(time.RFC3339)
288+
}
289+
}
290+
return fmt.Sprintf("%v", v.Interface())
291+
}
292+
293+
func upperAll(ss []string) []string {
294+
out := make([]string, len(ss))
295+
for i, s := range ss {
296+
out[i] = strings.ToUpper(s)
297+
}
298+
return out
299+
}
300+
301+
func truncate(s string, n int) string {
302+
s = strings.ReplaceAll(s, "\n", " ")
303+
if len([]rune(s)) <= n {
304+
return s
305+
}
306+
rs := []rune(s)
307+
return string(rs[:n-1]) + "..."
308+
}

0 commit comments

Comments
 (0)