From dd3c7286d27688e6b17037e94d522b5ca6cce968 Mon Sep 17 00:00:00 2001 From: ScuroNeko Date: Tue, 28 Apr 2026 14:36:03 +0300 Subject: [PATCH] (new): JSON HTML escaping control - add Formatter.JSONEscapeHTML and SetJSONEscapeHTML to let JSON writers preserve HTML-sensitive characters when needed while keeping escaping enabled by default. - switch JSON output to json.Encoder, preserve existing Print/Println newline semantics, and cover the new escaping behavior in tests. --- RELEASE_NOTES.md | 23 +++++++++++++++++++++++ examples/main.go | 2 ++ formatter.go | 21 ++++++++++++++++++--- logger_test.go | 39 +++++++++++++++++++++++++++++++++++++++ writers.go | 25 ++++++++++++++++--------- 5 files changed, 98 insertions(+), 12 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5dedd9c..f470941 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,26 @@ +# v2.3.0 + +This release adds JSON HTML-escaping control while preserving the existing +JSON writer defaults and newline semantics. + +### Added + +- Added `Formatter.SetJSONEscapeHTML(bool)` and `Formatter.JSONEscapeHTML` to control whether JSON writers escape HTML-sensitive characters such as `<`, `>`, and `&`. + +### Changed + +- JSON writers now use `json.Encoder` internally so formatter-level HTML escaping can be configured. + +### Fixed + +- Preserved `Print` and `Println` newline behavior for JSON output while switching to `json.Encoder`. + +### Compatibility + +- No breaking API changes. +- HTML escaping remains enabled by default, matching the previous `encoding/json` behavior. +- `Print` still emits JSON without an added newline, and `Println` emits exactly one trailing newline. + # v2.2.0 This release expands the public LogLevel API, adds optional threshold-based diff --git a/examples/main.go b/examples/main.go index 023554a..c4a8e6a 100644 --- a/examples/main.go +++ b/examples/main.go @@ -32,6 +32,7 @@ func main() { jsonFormatter := sneklog.NewFormatter(). SetFormat("[%L] [%N] %m"). SetColorOutput(false). + SetJSONEscapeHTML(false). SetTimeStampFormat(sneklog.Kitchen) textStdout.SetFormatter(formatter) @@ -62,6 +63,7 @@ func main() { externalJSON, ) + logger.Infoln("Тест <>&") logger.Infoln("service started") logger.Warnln("cache miss") logger.Errorln("request failed") diff --git a/formatter.go b/formatter.go index 90ef58e..3d9b716 100644 --- a/formatter.go +++ b/formatter.go @@ -32,9 +32,10 @@ type Formatter struct { Format string MessageSeparator string - // JSONTraceBackPath - // If true, in traceback will be path to file, otherwise only filename. - JSONTraceBackPath bool + // JSONTraceBackPath enables full file paths in formatted traceback fields. + JSONTraceBackPath bool + // JSONEscapeHTML controls whether JSON output escapes HTML-sensitive characters. + JSONEscapeHTML bool TimeStampFormat string TraceBackFormat string TraceBackSeparator string @@ -57,6 +58,7 @@ var DefaultTextFormatter = &Formatter{ // DefaultJsonFormatter is the default formatter used by JSON writers. var DefaultJsonFormatter = &Formatter{ Format: "%m", + JSONEscapeHTML: true, MessageSeparator: " ", TimeStampFormat: RFC3339, ColorOutput: false, @@ -67,6 +69,7 @@ func NewFormatter() *Formatter { return &Formatter{ Format: DefaultTextFormatter.Format, MessageSeparator: DefaultTextFormatter.MessageSeparator, + JSONEscapeHTML: true, TimeStampFormat: DefaultTextFormatter.TimeStampFormat, TraceBackFormat: DefaultTextFormatter.TraceBackFormat, TraceBackSeparator: DefaultTextFormatter.TraceBackSeparator, @@ -87,6 +90,18 @@ func (f *Formatter) SetMessageSeparator(separator string) *Formatter { return f } +// SetJSONTraceBackPath enables or disables full file paths in formatted traceback fields. +func (f *Formatter) SetJSONTraceBackPath(set bool) *Formatter { + f.JSONTraceBackPath = set + return f +} + +// SetJSONEscapeHTML enables or disables HTML escaping in JSON output. +func (f *Formatter) SetJSONEscapeHTML(escape bool) *Formatter { + f.JSONEscapeHTML = escape + return f +} + // SetTimeStampFormat sets the strftime-like format used for timestamps. func (f *Formatter) SetTimeStampFormat(format string) *Formatter { f.TimeStampFormat = format diff --git a/logger_test.go b/logger_test.go index d204efc..2b03025 100644 --- a/logger_test.go +++ b/logger_test.go @@ -342,6 +342,42 @@ func TestJsonWriterPrintAllowsEmptyMessages(t *testing.T) { } } +func TestJsonWriterPrintDoesNotAppendNewline(t *testing.T) { + var buf bytes.Buffer + writer := CreateJsonWriter(&buf, false) + + if err := writer.Print(INFO, "TEST", nil, "hello"); err != nil { + t.Fatalf("Print() error = %v", err) + } + + if bytes.HasSuffix(buf.Bytes(), []byte("\n")) { + t.Fatalf("Print() should not append newline, got %q", buf.String()) + } +} + +func TestJsonWriterSetJSONEscapeHTML(t *testing.T) { + var escaped bytes.Buffer + escapedWriter := CreateJsonWriter(&escaped, false) + + if err := escapedWriter.Print(INFO, "TEST", nil, "<>&"); err != nil { + t.Fatalf("Print() with default formatter error = %v", err) + } + if strings.Contains(escaped.String(), "<>&") { + t.Fatalf("default JSON output should escape HTML-sensitive characters, got %q", escaped.String()) + } + + var raw bytes.Buffer + rawWriter := CreateJsonWriter(&raw, false). + SetFormatter(NewFormatter().SetFormat("%m").SetJSONEscapeHTML(false)) + + if err := rawWriter.Print(INFO, "TEST", nil, "<>&"); err != nil { + t.Fatalf("Print() with SetJSONEscapeHTML(false) error = %v", err) + } + if !strings.Contains(raw.String(), "<>&") { + t.Fatalf("SetJSONEscapeHTML(false) should preserve HTML-sensitive characters, got %q", raw.String()) + } +} + func TestJsonWriterPrintPreservesTrailingNewlineSemantic(t *testing.T) { var buf bytes.Buffer writer := CreateJsonWriter(&buf, false) @@ -354,6 +390,9 @@ func TestJsonWriterPrintPreservesTrailingNewlineSemantic(t *testing.T) { if !bytes.HasSuffix(data, []byte("\n")) { t.Fatalf("JSON output should end with newline, got %q", data) } + if bytes.HasSuffix(bytes.TrimSuffix(data, []byte("\n")), []byte("\n")) { + t.Fatalf("JSON output should end with exactly one newline, got %q", data) + } var message LoggerJsonMessage if err := json.Unmarshal(bytes.TrimSuffix(data, []byte("\n")), &message); err != nil { diff --git a/writers.go b/writers.go index 91e0910..4bf0d4c 100644 --- a/writers.go +++ b/writers.go @@ -1,6 +1,7 @@ package sneklog import ( + "bytes" "encoding/json" "fmt" "io" @@ -145,21 +146,27 @@ func (w *LoggerJsonWriter) Print(level LogLevel, prefix string, traceback []*Met Message: s, Traceback: traceback, } - var data []byte - var err error + + buf := bytes.NewBuffer(nil) + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(f.JSONEscapeHTML) if w.pretty { - data, err = json.MarshalIndent(m, "", " ") - } else { - data, err = json.Marshal(m) + enc.SetIndent("", " ") } + + err := enc.Encode(m) if err != nil { return err } - if newline { - data = append(data, '\n') + data := buf.Bytes() + if !newline { + data = bytes.TrimSuffix(data, []byte("\n")) } - _, err = w.Write(data) - return err + _, err = w.writer.Write(data) + if err != nil { + return err + } + return nil } // Close closes the owned writer, if any.