From 47ec29d5072f940f75764b024cdb432910ab73fb Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:19:38 -0400 Subject: [PATCH 01/10] fix(printer): escape JSON keys and string values --- printer/json.go | 10 ++++++++-- printer/json_escape_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 printer/json_escape_test.go diff --git a/printer/json.go b/printer/json.go index e73de95..1521ca3 100644 --- a/printer/json.go +++ b/printer/json.go @@ -79,7 +79,7 @@ func writeJSONToken(w io.Writer, dec *json.Decoder, tok json.Token, enabled bool return fmt.Errorf("invalid json key") } - keyFormatted := color.Key(enabled, `"`+key+`"`) + ": " + keyFormatted := color.Key(enabled, quoteJSONString(key)) + ": " if _, err := io.WriteString(w, keyFormatted); err != nil { return err } @@ -133,7 +133,7 @@ func writeJSONToken(w io.Writer, dec *json.Decoder, tok json.Token, enabled bool return fmt.Errorf("unexpected delimiter %q", v) } case string: - strFormatted := color.String(enabled, `"`+v+`"`) + strFormatted := color.String(enabled, quoteJSONString(v)) if _, err := io.WriteString(w, strFormatted); err != nil { return err } @@ -160,3 +160,9 @@ func writeJSONToken(w io.Writer, dec *json.Decoder, tok json.Token, enabled bool } return nil } + +// Strings decoded from JSON must be escaped again before being printed. +func quoteJSONString(value string) string { + encoded, _ := json.Marshal(value) + return string(encoded) +} diff --git a/printer/json_escape_test.go b/printer/json_escape_test.go new file mode 100644 index 0000000..f12b799 --- /dev/null +++ b/printer/json_escape_test.go @@ -0,0 +1,28 @@ +package printer + +import ( + "bytes" + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestPrettyJSONEscapesDecodedStrings(t *testing.T) { + for _, input := range []string{`{"a\"b":"line\nnext\t\\end"}`, `["\b\f\r\t","\u0000"]`, `{"雪":"&"}`} { + var out bytes.Buffer + if _, err := PrettyJSON(&out, strings.NewReader(input), false); err != nil { + t.Fatal(err) + } + var want, got interface{} + if err := json.Unmarshal([]byte(input), &want); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("formatter produced invalid JSON: %q: %v", out.String(), err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("round trip = %#v; want %#v", got, want) + } + } +} From 1dbc27a2ef4063d344b5b83c838e609d7dbd7e41 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:20:54 -0400 Subject: [PATCH 02/10] fix(printer): report actual JSON bytes including partial writes --- printer/json.go | 32 +++++++++---------------- printer/json_count_test.go | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 21 deletions(-) create mode 100644 printer/json_count_test.go diff --git a/printer/json.go b/printer/json.go index 1521ca3..f188e0f 100644 --- a/printer/json.go +++ b/printer/json.go @@ -28,26 +28,25 @@ func getIndent(depth int) []byte { func PrettyJSON(w io.Writer, r io.Reader, enabled bool) (int64, error) { dec := json.NewDecoder(r) dec.UseNumber() - var count int64 - if err := writeJSONValue(w, dec, enabled, 0, &count); err != nil { - return count, err + cw := &countingWriter{w: w} + if err := writeJSONValue(cw, dec, enabled, 0); err != nil { + return cw.count, err } - if _, err := w.Write([]byte("\n")); err != nil { - return count, err + if _, err := cw.Write([]byte("\n")); err != nil { + return cw.count, err } - count++ - return count, nil + return cw.count, nil } -func writeJSONValue(w io.Writer, dec *json.Decoder, enabled bool, depth int, count *int64) error { +func writeJSONValue(w io.Writer, dec *json.Decoder, enabled bool, depth int) error { tok, err := dec.Token() if err != nil { return err } - return writeJSONToken(w, dec, tok, enabled, depth, count) + return writeJSONToken(w, dec, tok, enabled, depth) } -func writeJSONToken(w io.Writer, dec *json.Decoder, tok json.Token, enabled bool, depth int, count *int64) error { +func writeJSONToken(w io.Writer, dec *json.Decoder, tok json.Token, enabled bool, depth int) error { switch v := tok.(type) { case json.Delim: switch v { @@ -55,21 +54,18 @@ func writeJSONToken(w io.Writer, dec *json.Decoder, tok json.Token, enabled bool if _, err := w.Write([]byte("{\n")); err != nil { return err } - *count += 2 first := true for dec.More() { if !first { if _, err := w.Write([]byte(",\n")); err != nil { return err } - *count += 2 } first = false indent := getIndent(depth + 1) if _, err := w.Write(indent); err != nil { return err } - *count += int64(len(indent)) keyTok, err := dec.Token() if err != nil { return err @@ -83,8 +79,7 @@ func writeJSONToken(w io.Writer, dec *json.Decoder, tok json.Token, enabled bool if _, err := io.WriteString(w, keyFormatted); err != nil { return err } - *count += int64(len(key)) - if err := writeJSONValue(w, dec, enabled, depth+1, count); err != nil { + if err := writeJSONValue(w, dec, enabled, depth+1); err != nil { return err } } @@ -95,28 +90,24 @@ func writeJSONToken(w io.Writer, dec *json.Decoder, tok json.Token, enabled bool if _, err := io.WriteString(w, closeStr); err != nil { return err } - *count += int64(len(closeStr)) return nil case '[': if _, err := w.Write([]byte("[\n")); err != nil { return err } - *count += 2 first := true for dec.More() { if !first { if _, err := w.Write([]byte(",\n")); err != nil { return err } - *count += 2 } first = false indent := getIndent(depth + 1) if _, err := w.Write(indent); err != nil { return err } - *count += int64(len(indent)) - if err := writeJSONValue(w, dec, enabled, depth+1, count); err != nil { + if err := writeJSONValue(w, dec, enabled, depth+1); err != nil { return err } } @@ -127,7 +118,6 @@ func writeJSONToken(w io.Writer, dec *json.Decoder, tok json.Token, enabled bool if _, err := io.WriteString(w, closeStr); err != nil { return err } - *count += int64(len(closeStr)) return nil default: return fmt.Errorf("unexpected delimiter %q", v) diff --git a/printer/json_count_test.go b/printer/json_count_test.go new file mode 100644 index 0000000..af93411 --- /dev/null +++ b/printer/json_count_test.go @@ -0,0 +1,49 @@ +package printer + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +type failingJSONWriter struct { + buffer bytes.Buffer + remaining int + failure error +} + +func (w *failingJSONWriter) Write(p []byte) (int, error) { + if len(p) > w.remaining { + n, _ := w.buffer.Write(p[:w.remaining]) + w.remaining = 0 + return n, w.failure + } + n, _ := w.buffer.Write(p) + w.remaining -= n + return n, nil +} +func TestPrettyJSONReportsActualBytes(t *testing.T) { + t.Setenv("NO_COLOR", "") + for _, enabled := range []bool{false, true} { + var out bytes.Buffer + n, err := PrettyJSON(&out, strings.NewReader(`{"雪":["quoted\"",true,null,42]}`), enabled) + if err != nil { + t.Fatal(err) + } + if n != int64(out.Len()) { + t.Fatalf("color=%v reported %d bytes; wrote %d", enabled, n, out.Len()) + } + } +} +func TestPrettyJSONCountsPartialFailedWrites(t *testing.T) { + failure := errors.New("destination full") + out := &failingJSONWriter{remaining: 7, failure: failure} + n, err := PrettyJSON(out, strings.NewReader(`{"hello":"world"}`), false) + if !errors.Is(err, failure) { + t.Fatalf("error=%v", err) + } + if n != int64(out.buffer.Len()) { + t.Fatalf("reported %d bytes; wrote %d", n, out.buffer.Len()) + } +} From 53f6a0f46c02aa4392d1be5ef44100628e51514e Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:22:06 -0400 Subject: [PATCH 03/10] fix(printer): reject trailing JSON content and read failures --- printer/json.go | 6 +++++ printer/json_trailing_test.go | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 printer/json_trailing_test.go diff --git a/printer/json.go b/printer/json.go index f188e0f..e24fd17 100644 --- a/printer/json.go +++ b/printer/json.go @@ -32,6 +32,12 @@ func PrettyJSON(w io.Writer, r io.Reader, enabled bool) (int64, error) { if err := writeJSONValue(cw, dec, enabled, 0); err != nil { return cw.count, err } + if _, err := dec.Token(); err != io.EOF { + if err != nil { + return cw.count, err + } + return cw.count, fmt.Errorf("unexpected content after JSON value") + } if _, err := cw.Write([]byte("\n")); err != nil { return cw.count, err } diff --git a/printer/json_trailing_test.go b/printer/json_trailing_test.go new file mode 100644 index 0000000..5fc1b3a --- /dev/null +++ b/printer/json_trailing_test.go @@ -0,0 +1,42 @@ +package printer + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" +) + +type jsonTailErrorReader struct { + done bool + failure error +} + +func (r *jsonTailErrorReader) Read(p []byte) (int, error) { + if !r.done { + r.done = true + return copy(p, `{"ok":true}`), nil + } + return 0, r.failure +} +func TestPrettyJSONRejectsTrailingContent(t *testing.T) { + for _, input := range []string{`{} {}`, `[] garbage`, `true false`, `{"a":1}x`} { + var out bytes.Buffer + if _, err := PrettyJSON(&out, strings.NewReader(input), false); err == nil { + t.Fatalf("accepted trailing content: %q", input) + } + } +} +func TestPrettyJSONAcceptsTrailingWhitespace(t *testing.T) { + var out bytes.Buffer + if _, err := PrettyJSON(&out, strings.NewReader("{} \n\r\t"), false); err != nil { + t.Fatal(err) + } +} +func TestPrettyJSONPropagatesTrailingReadError(t *testing.T) { + failure := errors.New("response truncated") + if _, err := PrettyJSON(io.Discard, &jsonTailErrorReader{failure: failure}, false); !errors.Is(err, failure) { + t.Fatalf("error=%v; want %v", err, failure) + } +} From 464cc91354460145f3a2b66fa6dfca630a9c6c8a Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:22:46 -0400 Subject: [PATCH 04/10] fix(printer): preserve script and style raw text --- printer/html.go | 30 +++++++++------------ printer/html_raw_text_test.go | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 18 deletions(-) create mode 100644 printer/html_raw_text_test.go diff --git a/printer/html.go b/printer/html.go index 457d870..117cd23 100644 --- a/printer/html.go +++ b/printer/html.go @@ -102,28 +102,18 @@ func format(cw *countingWriter, n *html.Node, depth int, enabled bool, hasHtml, return nil } - // Format script and style block content as raw but indented text block + // Raw-text content can contain significant whitespace and template literals. if n.Data == "script" || n.Data == "style" { - start := renderStartTag(n, enabled) - if _, err := fmt.Fprint(cw, strings.Repeat(" ", depth)+start+"\n"); err != nil { + if _, err := fmt.Fprint(cw, strings.Repeat(" ", depth)+renderStartTag(n, enabled)); err != nil { return err } - if n.FirstChild != nil { - lines := strings.Split(n.FirstChild.Data, "\n") - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - if _, err := fmt.Fprint(cw, strings.Repeat(" ", depth+1)+trimmed+"\n"); err != nil { - return err - } - } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if _, err := fmt.Fprint(cw, c.Data); err != nil { + return err } } - end := renderEndTag(n, enabled) - if _, err := fmt.Fprint(cw, strings.Repeat(" ", depth)+end+"\n"); err != nil { - return err - } - return nil + _, err := fmt.Fprint(cw, renderEndTag(n, enabled)+"\n") + return err } // Handle empty elements @@ -182,7 +172,11 @@ func formatInline(sb *strings.Builder, n *html.Node, enabled bool) error { for c := n.FirstChild; c != nil; c = c.NextSibling { switch c.Type { case html.TextNode: - sb.WriteString(html.EscapeString(c.Data)) + if n.Data == "script" || n.Data == "style" { + sb.WriteString(c.Data) + } else { + sb.WriteString(html.EscapeString(c.Data)) + } case html.CommentNode: sb.WriteString(renderComment(c, enabled)) case html.ElementNode: diff --git a/printer/html_raw_text_test.go b/printer/html_raw_text_test.go new file mode 100644 index 0000000..816f2ba --- /dev/null +++ b/printer/html_raw_text_test.go @@ -0,0 +1,51 @@ +package printer + +import ( + "bytes" + "golang.org/x/net/html" + "strings" + "testing" +) + +func rawTextElements(t *testing.T, input string) []string { + t.Helper() + doc, err := html.Parse(strings.NewReader(input)) + if err != nil { + t.Fatal(err) + } + var values []string + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") { + var s strings.Builder + for c := n.FirstChild; c != nil; c = c.NextSibling { + s.WriteString(c.Data) + } + values = append(values, s.String()) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(doc) + return values +} +func TestPrettyHTMLPreservesRawText(t *testing.T) { + for _, input := range []string{"", "", `
`} { + t.Run(input, func(t *testing.T) { + var out bytes.Buffer + if _, err := PrettyHTML(&out, strings.NewReader(input), false); err != nil { + t.Fatal(err) + } + want, got := rawTextElements(t, input), rawTextElements(t, out.String()) + if len(want) != len(got) { + t.Fatalf("elements: %v vs %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("raw text changed: got %q, want %q", got[i], want[i]) + } + } + }) + } +} From c7dcf43bd57cc04d0604cca05d4eec4b60cc00b7 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:23:44 -0400 Subject: [PATCH 05/10] fix(printer): retain HTML attribute namespace prefixes --- printer/html.go | 11 +++++++++-- printer/html_namespace_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 printer/html_namespace_test.go diff --git a/printer/html.go b/printer/html.go index 117cd23..6c77b6e 100644 --- a/printer/html.go +++ b/printer/html.go @@ -231,7 +231,7 @@ func renderStartTag(n *html.Node, enabled bool) string { sb.WriteString(color.Key(enabled, n.Data)) for _, attr := range n.Attr { sb.WriteString(" ") - sb.WriteString(color.Number(enabled, attr.Key)) + sb.WriteString(color.Number(enabled, qualifiedAttributeName(attr))) sb.WriteString(color.Border(enabled, "=")) sb.WriteString(color.String(enabled, `"`+html.EscapeString(attr.Val)+`"`)) } @@ -253,7 +253,7 @@ func renderVoidTag(n *html.Node, enabled bool) string { sb.WriteString(color.Key(enabled, n.Data)) for _, attr := range n.Attr { sb.WriteString(" ") - sb.WriteString(color.Number(enabled, attr.Key)) + sb.WriteString(color.Number(enabled, qualifiedAttributeName(attr))) sb.WriteString(color.Border(enabled, "=")) sb.WriteString(color.String(enabled, `"`+html.EscapeString(attr.Val)+`"`)) } @@ -268,3 +268,10 @@ func renderComment(n *html.Node, enabled bool) string { func renderDoctype(n *html.Node, enabled bool) string { return color.Wrap(enabled, color.Bold+color.Magenta, "") } + +func qualifiedAttributeName(attr html.Attribute) string { + if attr.Namespace != "" { + return attr.Namespace + ":" + attr.Key + } + return attr.Key +} diff --git a/printer/html_namespace_test.go b/printer/html_namespace_test.go new file mode 100644 index 0000000..e9a3969 --- /dev/null +++ b/printer/html_namespace_test.go @@ -0,0 +1,27 @@ +package printer + +import ( + "bytes" + "golang.org/x/net/html" + "strings" + "testing" +) + +func TestPrettyHTMLPreservesNamespacedAttributes(t *testing.T) { + input := `` + var out bytes.Buffer + if _, err := PrettyHTML(&out, strings.NewReader(input), false); err != nil { + t.Fatal(err) + } + for _, attribute := range []string{`xmlns:xlink="http://www.w3.org/1999/xlink"`, `xlink:href="#shape"`, `xml:lang="en"`} { + if !strings.Contains(out.String(), attribute) { + t.Errorf("missing qualified attribute %s in %s", attribute, out.String()) + } + } +} +func TestRenderVoidTagPreservesAttributeNamespace(t *testing.T) { + node := &html.Node{Type: html.ElementNode, Data: "br", Attr: []html.Attribute{{Namespace: "xml", Key: "lang", Val: "en"}}} + if got := renderVoidTag(node, false); got != `
` { + t.Fatalf("void tag=%q", got) + } +} From e9821783609dda03be74214ac12158df41ff3c6b Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:09:02 -0400 Subject: [PATCH 06/10] fix(printer): recognize structured JSON and XHTML media types --- printer/media_type_test.go | 37 +++++++++++++++++++++++++++++++++++++ printer/printer.go | 23 ++++++++++++++++------- 2 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 printer/media_type_test.go diff --git a/printer/media_type_test.go b/printer/media_type_test.go new file mode 100644 index 0000000..bacb1f0 --- /dev/null +++ b/printer/media_type_test.go @@ -0,0 +1,37 @@ +package printer + +import ( + "bytes" + "github.com/kavix/kurl/client" + "io" + "net/http" + "strings" + "testing" +) + +func TestRenderStructuredMediaTypes(t *testing.T) { + for _, tc := range []struct{ media, body, want string }{ + {"application/problem+json", `{"message":"missing"}`, `"message"`}, + {"application/vnd.api+json; charset=utf-8", `{"message":"missing"}`, `"message"`}, + {"application/xhtml+xml", "

hello

", "

hello

"}, + } { + t.Run(tc.media, func(t *testing.T) { + var out bytes.Buffer + r := &client.Result{Response: &http.Response{Header: http.Header{"Content-Type": {tc.media}}, Body: io.NopCloser(strings.NewReader(tc.body))}} + if err := renderBody(&out, r, Options{}); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), tc.want) { + t.Fatalf("body hidden: %q", out.String()) + } + }) + } +} +func TestMediaTypeParametersDoNotSelectFormatter(t *testing.T) { + if isJSON(`text/plain; note="application/json"`, 0) { + t.Fatal("parameter selected JSON formatter") + } + if isHTML(`text/plain; note="text/html"`) { + t.Fatal("parameter selected HTML formatter") + } +} diff --git a/printer/printer.go b/printer/printer.go index 03d41fc..5f24773 100644 --- a/printer/printer.go +++ b/printer/printer.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "io" + "mime" "os" "path/filepath" "strings" @@ -257,22 +258,30 @@ func saveBodyToFile(w io.Writer, body io.Reader, outputPath string) error { return err } +func mediaType(contentType string) string { + value, _, err := mime.ParseMediaType(contentType) + if err != nil { + return "" + } + return strings.ToLower(value) +} + func isJSON(contentType string, length int64) bool { - contentType = strings.ToLower(contentType) - return strings.Contains(contentType, "application/json") || strings.Contains(contentType, "+json") + value := mediaType(contentType) + return value == "application/json" || strings.HasSuffix(value, "+json") } func isBinary(contentType string) bool { - contentType = strings.ToLower(contentType) - if contentType == "" { + value := mediaType(contentType) + if value == "" || isJSON(contentType, 0) || isHTML(contentType) { return false } - return !strings.HasPrefix(contentType, "text/") && !strings.Contains(contentType, "/json") + return !strings.HasPrefix(value, "text/") } func isHTML(contentType string) bool { - contentType = strings.ToLower(contentType) - return strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") + value := mediaType(contentType) + return value == "text/html" || value == "application/xhtml+xml" } // RenderTiming prints the per-phase request timing breakdown. The client From 89def1df3edb7cf18a41420a94facfb427c6b8db Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:18:06 -0400 Subject: [PATCH 07/10] fix(printer): retain whitespace inside preformatted HTML --- printer/html.go | 7 ++++- printer/preformatted_test.go | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 printer/preformatted_test.go diff --git a/printer/html.go b/printer/html.go index 6c77b6e..fc5072e 100644 --- a/printer/html.go +++ b/printer/html.go @@ -131,7 +131,7 @@ func format(cw *countingWriter, n *html.Node, depth int, enabled bool, hasHtml, } // Format simple inline elements (and their inline children) on a single line - if hasOnlyInlineChildren(n) { + if n.Data == "pre" || hasOnlyInlineChildren(n) { var sb strings.Builder sb.WriteString(renderStartTag(n, enabled)) if err := formatInline(&sb, n, enabled); err != nil { @@ -169,6 +169,11 @@ func format(cw *countingWriter, n *html.Node, depth int, enabled bool, hasHtml, } func formatInline(sb *strings.Builder, n *html.Node, enabled bool) error { + // HTML parsing removes a first newline inside pre/textarea. Restore that + // sentinel when the parsed text itself starts with a newline. + if (n.Data == "pre" || n.Data == "textarea") && n.FirstChild != nil && n.FirstChild.Type == html.TextNode && strings.HasPrefix(n.FirstChild.Data, "\n") { + sb.WriteString("\n") + } for c := n.FirstChild; c != nil; c = c.NextSibling { switch c.Type { case html.TextNode: diff --git a/printer/preformatted_test.go b/printer/preformatted_test.go new file mode 100644 index 0000000..c302bd0 --- /dev/null +++ b/printer/preformatted_test.go @@ -0,0 +1,50 @@ +package printer + +import ( + "bytes" + "golang.org/x/net/html" + "strings" + "testing" +) + +func preText(t *testing.T, input string) string { + t.Helper() + doc, err := html.Parse(strings.NewReader(input)) + if err != nil { + t.Fatal(err) + } + var result string + var visit func(*html.Node, bool) + visit = func(n *html.Node, inPre bool) { + inPre = inPre || (n.Type == html.ElementNode && n.Data == "pre") + if inPre && n.Type == html.TextNode { + result += n.Data + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + visit(c, inPre) + } + } + visit(doc, false) + return result +} +func TestPrettyHTMLPreservesPreformattedBlockChildren(t *testing.T) { + input := "
  first\n
second\n\n third
last\n
" + var out bytes.Buffer + if _, err := PrettyHTML(&out, strings.NewReader(input), false); err != nil { + t.Fatal(err) + } + if got, want := preText(t, out.String()), preText(t, input); got != want { + t.Fatalf("pre text changed: got %q want %q", got, want) + } +} + +func TestPrettyHTMLPreservesLeadingPreformattedNewline(t *testing.T) { + input := "
\n\ntext
" + var out bytes.Buffer + if _, err := PrettyHTML(&out, strings.NewReader(input), false); err != nil { + t.Fatal(err) + } + if preText(t, out.String()) != preText(t, input) { + t.Fatalf("leading newline lost: %q", out.String()) + } +} From f996ae77db8e25b95d2060e6df954ec3c2e4da98 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:38:17 -0400 Subject: [PATCH 08/10] Preserve public and system identifiers in HTML doctypes --- printer/doctype_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ printer/html.go | 4 +++- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 printer/doctype_test.go diff --git a/printer/doctype_test.go b/printer/doctype_test.go new file mode 100644 index 0000000..1a339ae --- /dev/null +++ b/printer/doctype_test.go @@ -0,0 +1,41 @@ +package printer + +import ( + "bytes" + "reflect" + "strings" + "testing" + + "golang.org/x/net/html" +) + +func documentTypeAttributes(t *testing.T, text string) []html.Attribute { + t.Helper() + doc, err := html.Parse(strings.NewReader(text)) + if err != nil { + t.Fatal(err) + } + for n := doc.FirstChild; n != nil; n = n.NextSibling { + if n.Type == html.DoctypeNode { + return n.Attr + } + } + t.Fatal("doctype missing") + return nil +} + +func TestPrettyHTMLPreservesDoctypeIdentifiers(t *testing.T) { + for _, source := range []string{ + `

Body

`, + `

Body

`, + `

Body

`, + } { + var output bytes.Buffer + if _, err := PrettyHTML(&output, strings.NewReader(source), false); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(documentTypeAttributes(t, source), documentTypeAttributes(t, output.String())) { + t.Fatalf("doctype identifiers lost: %s", output.String()) + } + } +} diff --git a/printer/html.go b/printer/html.go index fc5072e..8154adf 100644 --- a/printer/html.go +++ b/printer/html.go @@ -271,7 +271,9 @@ func renderComment(n *html.Node, enabled bool) string { } func renderDoctype(n *html.Node, enabled bool) string { - return color.Wrap(enabled, color.Bold+color.Magenta, "") + var serialized strings.Builder + _ = html.Render(&serialized, n) // strings.Builder cannot fail to write. + return color.Wrap(enabled, color.Bold+color.Magenta, serialized.String()) } func qualifiedAttributeName(attr html.Attribute) string { From c2ff913c3184de0ea8c952a0c1ccf4718ea0ec88 Mon Sep 17 00:00:00 2001 From: Rupayon Haldar <80724680+rupayon123@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:46:26 -0400 Subject: [PATCH 09/10] Detect actual document tags when formatting HTML fragments --- printer/fragment_detection_test.go | 25 +++++++++++++++++++++++++ printer/html.go | 24 ++++++++++++++++++++---- 2 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 printer/fragment_detection_test.go diff --git a/printer/fragment_detection_test.go b/printer/fragment_detection_test.go new file mode 100644 index 0000000..4c8131d --- /dev/null +++ b/printer/fragment_detection_test.go @@ -0,0 +1,25 @@ +package printer + +import ( + "bytes" + "strings" + "testing" +) + +func TestPrettyHTMLIgnoresTagNamesInCommentsAndRawText(t *testing.T) { + for _, source := range []string{ + `

Fragment

`, + ``, + `Fragment`, + } { + var output bytes.Buffer + if _, err := PrettyHTML(&output, strings.NewReader(source), false); err != nil { + t.Fatal(err) + } + for _, wrapper := range []string{"", "", ""} { + if strings.Contains(output.String(), wrapper) { + t.Fatalf("inserted wrapper %s: %s", wrapper, output.String()) + } + } + } +} diff --git a/printer/html.go b/printer/html.go index 8154adf..41fa856 100644 --- a/printer/html.go +++ b/printer/html.go @@ -23,10 +23,26 @@ func PrettyHTML(w io.Writer, r io.Reader, enabled bool) (int64, error) { return 0, err } - contentStr := string(data) - hasHtml := strings.Contains(strings.ToLower(contentStr), " Date: Wed, 16 Sep 2026 10:52:21 -0400 Subject: [PATCH 10/10] Display XML media types as readable response bodies --- printer/printer.go | 3 ++- printer/xml_response_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 printer/xml_response_test.go diff --git a/printer/printer.go b/printer/printer.go index 5f24773..852f83c 100644 --- a/printer/printer.go +++ b/printer/printer.go @@ -273,7 +273,8 @@ func isJSON(contentType string, length int64) bool { func isBinary(contentType string) bool { value := mediaType(contentType) - if value == "" || isJSON(contentType, 0) || isHTML(contentType) { + if value == "" || isJSON(contentType, 0) || isHTML(contentType) || + value == "application/xml" || strings.HasSuffix(value, "+xml") { return false } return !strings.HasPrefix(value, "text/") diff --git a/printer/xml_response_test.go b/printer/xml_response_test.go new file mode 100644 index 0000000..4cc420a --- /dev/null +++ b/printer/xml_response_test.go @@ -0,0 +1,32 @@ +package printer + +import ( + "bytes" + "io" + "net/http" + "strings" + "testing" + + "github.com/kavix/kurl/client" +) + +func TestRenderXMLMediaTypesAsText(t *testing.T) { + for _, media := range []string{"application/xml", "application/atom+xml; charset=utf-8", "image/svg+xml"} { + t.Run(media, func(t *testing.T) { + body := `hello & goodbye` + result := &client.Result{Response: &http.Response{Header: http.Header{"Content-Type": {media}}, Body: io.NopCloser(strings.NewReader(body))}} + var output bytes.Buffer + if err := renderBody(&output, result, Options{}); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), body) { + t.Fatalf("XML hidden: %s", output.String()) + } + }) + } + for _, media := range []string{"application/pdf", "image/png", "application/octet-stream"} { + if !isBinary(media) { + t.Errorf("binary type %s classified as text", media) + } + } +}