From 1000548a2fea99bfc978c971db543c82ebb8fc16 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Mon, 26 Jan 2026 20:17:36 -0800 Subject: [PATCH] Add formatting support for CSS --- README.md | 2 +- docs/USAGE.md | 1 + internal/fetch/fetch.go | 7 + internal/format/css.go | 941 +++++++++++++++++++++++++++++++++++ internal/format/css_test.go | 646 ++++++++++++++++++++++++ internal/format/html.go | 24 +- internal/format/html_test.go | 132 +++++ 7 files changed, 1748 insertions(+), 5 deletions(-) create mode 100644 internal/format/css.go create mode 100644 internal/format/css_test.go diff --git a/README.md b/README.md index 23a0495..380667e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ### Features include: -- **Response formatting**: automatically formats and colors output (json, html, csv, msgpack, protobuf, xml, etc.) +- **Response formatting**: automatically formats and colors output (json, html, xml, css, csv, msgpack, protobuf, etc.) - **Image rendering**: render images directly in your terminal - **Compression**: automatic gzip and zstd response body decompression - **Authentication**: support for Basic Auth, Bearer Token, and AWS Signature V4 diff --git a/docs/USAGE.md b/docs/USAGE.md index 39b8d32..ff26455 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -336,6 +336,7 @@ Supported formats for automatic formatting and syntax highlighting: - JSON (`application/json`) - HTML (`text/html`) - XML (`application/xml`, `text/xml`) +- CSS (`text/css`) - CSV (`text/csv`) - MessagePack (`application/msgpack`) - NDJSON/JSONLines (`application/x-ndjson`) diff --git a/internal/fetch/fetch.go b/internal/fetch/fetch.go index 5d78180..a461798 100644 --- a/internal/fetch/fetch.go +++ b/internal/fetch/fetch.go @@ -28,6 +28,7 @@ type ContentType int const ( TypeUnknown ContentType = iota + TypeCSS TypeCSV TypeHTML TypeImage @@ -286,6 +287,10 @@ func formatResponse(ctx context.Context, r *Request, resp *http.Response) (io.Re } switch contentType { + case TypeCSS: + if format.FormatCSS(buf, p) == nil { + buf = p.Bytes() + } case TypeCSV: if format.FormatCSV(buf, p) == nil { buf = p.Bytes() @@ -357,6 +362,8 @@ func getContentType(headers http.Header) ContentType { } case "text": switch subtype { + case "css": + return TypeCSS case "csv": return TypeCSV case "html": diff --git a/internal/format/css.go b/internal/format/css.go new file mode 100644 index 0000000..8b793e8 --- /dev/null +++ b/internal/format/css.go @@ -0,0 +1,941 @@ +package format + +import "github.com/ryanfowler/fetch/internal/core" + +// CSS token types +type cssTokenType int + +const ( + cssTokenEOF cssTokenType = iota + cssTokenIdent // property names, selectors, keywords + cssTokenHash // #id selectors + cssTokenAtKeyword // @media, @import, @keyframes + cssTokenString // "..." or '...' + cssTokenNumber // 123, 1.5 + cssTokenDimension // 10px, 2em, 100% + cssTokenFunction // calc(, rgba(, url( + cssTokenComment // /* ... */ + cssTokenDelim // single chars: { } : ; , . > + ~ * [ ] ( ) + cssTokenWhitespace // spaces, tabs, newlines +) + +// cssToken represents a single token from the CSS input. +type cssToken struct { + typ cssTokenType + value string +} + +// cssTokenizer tokenizes CSS input byte by byte. +type cssTokenizer struct { + input []byte + pos int +} + +func newCSSTokenizer(input []byte) *cssTokenizer { + return &cssTokenizer{input: input} +} + +func (t *cssTokenizer) peek() byte { + if t.pos >= len(t.input) { + return 0 + } + return t.input[t.pos] +} + +func (t *cssTokenizer) peekN(n int) byte { + if t.pos+n >= len(t.input) { + return 0 + } + return t.input[t.pos+n] +} + +func (t *cssTokenizer) advance() byte { + if t.pos >= len(t.input) { + return 0 + } + b := t.input[t.pos] + t.pos++ + return b +} + +func (t *cssTokenizer) next() cssToken { + // Skip whitespace, but return a whitespace token if any was found + if t.consumeWhitespace() { + return cssToken{typ: cssTokenWhitespace, value: " "} + } + + if t.pos >= len(t.input) { + return cssToken{typ: cssTokenEOF} + } + + c := t.peek() + + // Comment + if c == '/' && t.peekN(1) == '*' { + return t.scanComment() + } + + // String + if c == '"' || c == '\'' { + return t.scanString(c) + } + + // At-keyword + if c == '@' { + return t.scanAtKeyword() + } + + // Hash (ID selector) + if c == '#' { + return t.scanHash() + } + + // Number or dimension + if isDigit(c) || (c == '.' && isDigit(t.peekN(1))) || (c == '-' && (isDigit(t.peekN(1)) || t.peekN(1) == '.')) { + return t.scanNumber() + } + + // Identifier or function + if isIdentStart(c) || c == '-' || c == '_' { + return t.scanIdentOrFunction() + } + + // Single character delimiters + t.advance() + return cssToken{typ: cssTokenDelim, value: string(c)} +} + +func (t *cssTokenizer) consumeWhitespace() bool { + found := false + for t.pos < len(t.input) { + c := t.peek() + if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' { + t.advance() + found = true + } else { + break + } + } + return found +} + +func (t *cssTokenizer) scanComment() cssToken { + start := t.pos + t.advance() // consume / + t.advance() // consume * + + for t.pos < len(t.input) { + if t.peek() == '*' && t.peekN(1) == '/' { + t.advance() // consume * + t.advance() // consume / + break + } + t.advance() + } + + return cssToken{typ: cssTokenComment, value: string(t.input[start:t.pos])} +} + +func (t *cssTokenizer) scanString(quote byte) cssToken { + start := t.pos + t.advance() // consume opening quote + + for t.pos < len(t.input) { + c := t.peek() + if c == quote { + t.advance() + break + } + if c == '\\' { + t.advance() // consume backslash + if t.pos < len(t.input) { + t.advance() // consume escaped char + } + continue + } + if c == '\n' || c == '\r' { + // Unterminated string + break + } + t.advance() + } + + return cssToken{typ: cssTokenString, value: string(t.input[start:t.pos])} +} + +func (t *cssTokenizer) scanAtKeyword() cssToken { + start := t.pos + t.advance() // consume @ + + for t.pos < len(t.input) { + c := t.peek() + if isIdentChar(c) { + t.advance() + } else { + break + } + } + + return cssToken{typ: cssTokenAtKeyword, value: string(t.input[start:t.pos])} +} + +func (t *cssTokenizer) scanHash() cssToken { + start := t.pos + t.advance() // consume # + + for t.pos < len(t.input) { + c := t.peek() + if isIdentChar(c) { + t.advance() + } else { + break + } + } + + return cssToken{typ: cssTokenHash, value: string(t.input[start:t.pos])} +} + +func (t *cssTokenizer) scanNumber() cssToken { + start := t.pos + + // Optional sign + if t.peek() == '-' || t.peek() == '+' { + t.advance() + } + + // Integer part + for t.pos < len(t.input) && isDigit(t.peek()) { + t.advance() + } + + // Decimal part + if t.peek() == '.' && isDigit(t.peekN(1)) { + t.advance() // consume . + for t.pos < len(t.input) && isDigit(t.peek()) { + t.advance() + } + } + + // Check for unit (makes it a dimension) + if isIdentStart(t.peek()) || t.peek() == '%' { + if t.peek() == '%' { + t.advance() + } else { + for t.pos < len(t.input) && isIdentChar(t.peek()) { + t.advance() + } + } + return cssToken{typ: cssTokenDimension, value: string(t.input[start:t.pos])} + } + + return cssToken{typ: cssTokenNumber, value: string(t.input[start:t.pos])} +} + +func (t *cssTokenizer) scanIdentOrFunction() cssToken { + start := t.pos + + // Allow leading - or -- + for t.peek() == '-' { + t.advance() + } + + for t.pos < len(t.input) { + c := t.peek() + if isIdentChar(c) { + t.advance() + } else { + break + } + } + + value := string(t.input[start:t.pos]) + + // Check if it's a function (followed by open paren) + if t.peek() == '(' { + t.advance() + return cssToken{typ: cssTokenFunction, value: value + "("} + } + + return cssToken{typ: cssTokenIdent, value: value} +} + +func isDigit(c byte) bool { + return c >= '0' && c <= '9' +} + +func isIdentStart(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || c >= 0x80 +} + +func isIdentChar(c byte) bool { + return isIdentStart(c) || isDigit(c) || c == '-' +} + +// cssFormatter formats CSS tokens with pretty-printing and colors. +type cssFormatter struct { + tok *cssTokenizer + printer *core.Printer + indent int + current cssToken + atNewline bool + wroteRule bool // tracks if we've written any top-level rules (for blank line separation) +} + +// FormatCSS formats the provided CSS to the Printer. +func FormatCSS(buf []byte, p *core.Printer) error { + return FormatCSSIndented(buf, p, 0) +} + +// FormatCSSIndented formats CSS with a base indentation level. +// Used for formatting CSS embedded in HTML ` + p := core.NewHandle(core.ColorOff).Stderr() + err := FormatHTML([]byte(input), p) + if err != nil { + t.Fatalf("FormatHTML() error = %v", err) + } + + output := string(p.Bytes()) + // Should contain formatted CSS with body selector and color property. + if !strings.Contains(output, "body") { + t.Errorf("output should contain 'body', got: %s", output) + } + if !strings.Contains(output, "color") { + t.Errorf("output should contain 'color', got: %s", output) + } + if !strings.Contains(output, "red") { + t.Errorf("output should contain 'red', got: %s", output) + } + }) + + t.Run("nested HTML with style", func(t *testing.T) { + input := `` + p := core.NewHandle(core.ColorOff).Stderr() + err := FormatHTML([]byte(input), p) + if err != nil { + t.Fatalf("FormatHTML() error = %v", err) + } + + output := string(p.Bytes()) + // Should contain formatted CSS. + if !strings.Contains(output, ".a") { + t.Errorf("output should contain '.a', got: %s", output) + } + if !strings.Contains(output, "margin") { + t.Errorf("output should contain 'margin', got: %s", output) + } + }) + + t.Run("empty style tag", func(t *testing.T) { + input := `` + p := core.NewHandle(core.ColorOff).Stderr() + err := FormatHTML([]byte(input), p) + if err != nil { + t.Fatalf("FormatHTML() error = %v", err) + } + + output := string(p.Bytes()) + // Should just have the style tags. + if !strings.Contains(output, "") { + t.Errorf("output should contain '', got: %s", output) + } + }) + + t.Run("whitespace-only style", func(t *testing.T) { + input := `` + p := core.NewHandle(core.ColorOff).Stderr() + err := FormatHTML([]byte(input), p) + if err != nil { + t.Fatalf("FormatHTML() error = %v", err) + } + + output := string(p.Bytes()) + // Should contain style tags but no CSS content other than newlines. + if !strings.Contains(output, "` + p := core.NewHandle(core.ColorOff).Stderr() + err := FormatHTML([]byte(input), p) + if err != nil { + t.Fatalf("FormatHTML() error = %v", err) + } + + output := string(p.Bytes()) + // Both should be formatted. + if !strings.Contains(output, ".a") { + t.Errorf("output should contain '.a', got: %s", output) + } + if !strings.Contains(output, ".b") { + t.Errorf("output should contain '.b', got: %s", output) + } + }) + + t.Run("complex CSS in nested HTML", func(t *testing.T) { + input := `` + p := core.NewHandle(core.ColorOff).Stderr() + err := FormatHTML([]byte(input), p) + if err != nil { + t.Fatalf("FormatHTML() error = %v", err) + } + + output := string(p.Bytes()) + // Should contain formatted CSS with multiple rules. + if !strings.Contains(output, "body") { + t.Errorf("output should contain 'body', got: %s", output) + } + if !strings.Contains(output, ".container") { + t.Errorf("output should contain '.container', got: %s", output) + } + if !strings.Contains(output, "display") { + t.Errorf("output should contain 'display', got: %s", output) + } + if !strings.Contains(output, "flex") { + t.Errorf("output should contain 'flex', got: %s", output) + } + }) +} + func TestEscapeHTMLAttrValue(t *testing.T) { tests := []struct { name string