From 6cb86a13389e01d9e3fdae55ff2067c11dfcfded Mon Sep 17 00:00:00 2001 From: Asish Kumar Date: Mon, 25 May 2026 03:17:45 +0530 Subject: [PATCH] feat: detect extensionless remote JSON responses Remote HTTP sources without file extensions were always treated as markdown, even when the server identified the payload as JSON. That made API endpoints render as prose instead of a highlighted code block. Record the HTTP Content-Type for remote sources and use JSON media types, including +json variants, as the code block language only when the URL has no extension. Fixes #910 --- github.go | 2 +- gitlab.go | 2 +- glow_test.go | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 47 +++++++++++++++++++++++++++++++++++++++------- 4 files changed, 95 insertions(+), 9 deletions(-) diff --git a/github.go b/github.go index fe862e3..326e159 100644 --- a/github.go +++ b/github.go @@ -49,7 +49,7 @@ func findGitHubREADME(u *url.URL) (*source, error) { } if resp.StatusCode == http.StatusOK { - return &source{resp.Body, result.DownloadURL}, nil + return &source{reader: resp.Body, URL: result.DownloadURL}, nil } } diff --git a/gitlab.go b/gitlab.go index 68256be..5b3cb5f 100644 --- a/gitlab.go +++ b/gitlab.go @@ -53,7 +53,7 @@ func findGitLabREADME(u *url.URL) (*source, error) { } if resp.StatusCode == http.StatusOK { - return &source{resp.Body, readmeRawURL}, nil + return &source{reader: resp.Body, URL: readmeRawURL}, nil } } diff --git a/glow_test.go b/glow_test.go index 8743be2..7353a17 100644 --- a/glow_test.go +++ b/glow_test.go @@ -1,6 +1,9 @@ package main import ( + "fmt" + "net/http" + "net/http/httptest" "testing" ) @@ -39,3 +42,53 @@ func TestGlowFlags(t *testing.T) { } } } + +func TestSourceDetectsExtensionlessJSONContentType(t *testing.T) { + src := source{ + URL: "https://api.example.com/endpoint", + contentType: "application/vnd.api+json; charset=utf-8", + } + + if !src.isCode() { + t.Fatal("expected extensionless JSON response to render as code") + } + if got := src.codeBlockLanguage(); got != ".json" { + t.Fatalf("expected .json code block language, got %q", got) + } +} + +func TestSourceMarkdownExtensionTakesPrecedenceOverContentType(t *testing.T) { + src := source{ + URL: "https://example.com/README.md", + contentType: "application/json", + } + + if src.isCode() { + t.Fatal("expected markdown extension to render as markdown") + } +} + +func TestSourceFromArgRecordsHTTPContentType(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + fmt.Fprint(w, `{"ok":true}`) + })) + t.Cleanup(server.Close) + + src, err := sourceFromArg(server.URL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := src.reader.Close(); err != nil { + t.Fatal(err) + } + }) + + if got := src.contentType; got != "application/json; charset=utf-8" { + t.Fatalf("expected content type to be recorded, got %q", got) + } + if !src.isCode() { + t.Fatal("expected extensionless JSON HTTP source to render as code") + } +} diff --git a/main.go b/main.go index b31ca15..7e167c2 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "io/fs" + "mime" "net/http" "net/url" "os" @@ -67,8 +68,9 @@ var ( // source provides a readable markdown source. type source struct { - reader io.ReadCloser - URL string + reader io.ReadCloser + URL string + contentType string } // sourceFromArg parses an argument and creates a readable source for it. @@ -99,7 +101,11 @@ func sourceFromArg(arg string) (*source, error) { if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP status %d", resp.StatusCode) } - return &source{resp.Body, u.String()}, nil + return &source{ + reader: resp.Body, + URL: u.String(), + contentType: resp.Header.Get("Content-Type"), + }, nil } } @@ -123,7 +129,7 @@ func sourceFromArg(arg string) (*source, error) { } u, _ := filepath.Abs(path) - src = &source{r, u} + src = &source{reader: r, URL: u} // abort filepath.Walk return errors.New("source found") @@ -147,7 +153,34 @@ func sourceFromArg(arg string) (*source, error) { if err != nil { return nil, fmt.Errorf("unable to get absolute path: %w", err) } - return &source{r, u}, nil + return &source{reader: r, URL: u}, nil +} + +func (s source) isCode() bool { + if !utils.IsMarkdownFile(s.URL) { + return true + } + return filepath.Ext(s.URL) == "" && s.codeBlockLanguage() != "" +} + +func (s source) codeBlockLanguage() string { + if ext := filepath.Ext(s.URL); ext != "" { + return ext + } + return codeBlockLanguageFromContentType(s.contentType) +} + +func codeBlockLanguageFromContentType(contentType string) string { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0]) + } + mediaType = strings.ToLower(mediaType) + + if mediaType == "application/json" || strings.HasSuffix(mediaType, "+json") { + return ".json" + } + return "" } // validateStyle checks if the style is a default style, if not, checks that @@ -288,7 +321,7 @@ func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error { baseURL = u.String() + "/" } - isCode := !utils.IsMarkdownFile(src.URL) + isCode := src.isCode() // initialize glamour r, err := glamour.NewTermRenderer( @@ -303,7 +336,7 @@ func executeCLI(cmd *cobra.Command, src *source, w io.Writer) error { } content := string(b) - ext := filepath.Ext(src.URL) + ext := src.codeBlockLanguage() if isCode { content = utils.WrapCodeBlock(string(b), ext) }