├── .gitignore ├── stylesheet.go ├── go.mod ├── doc.go ├── stylerule.go ├── rule_test.go ├── styledeclaration_test.go ├── stylerule_test.go ├── README.md ├── .github ├── dependabot.yml └── workflows │ └── go.yml ├── styledeclaration.go ├── skip_rule.go ├── at_parser_charset_test.go ├── go.sum ├── rule.go ├── LICENSE ├── at_parser.go ├── value_test.go ├── block_parser_test.go ├── at_parser_import_test.go ├── selector_parser.go ├── value.go ├── parser_keyframes_test.go ├── block_parser.go ├── parser_media_test.go ├── parser_test.go ├── parser.go └── parser_selector_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | /src/ 2 | /pkg/ 3 | /bin/ 4 | -------------------------------------------------------------------------------- /stylesheet.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | type CSSStyleSheet struct { 4 | Type string 5 | Media string 6 | CssRuleList []*CSSRule 7 | } 8 | 9 | func (ss *CSSStyleSheet) GetCSSRuleList() []*CSSRule { 10 | return ss.CssRuleList 11 | } 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vanng822/css 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/gorilla/css v1.0.1 7 | github.com/stretchr/testify v1.11.1 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | gopkg.in/yaml.v3 v3.0.1 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package css is for parsing css stylesheet. 2 | // 3 | // import ( 4 | // "github.com/vanng822/css" 5 | // "fmt" 6 | // ) 7 | // func main() { 8 | // csstext = "td {width: 100px; height: 100px;}" 9 | // ss := css.Parse(csstext) 10 | // rules := ss.GetCSSRuleList() 11 | // for _, rule := range rules { 12 | // fmt.Println(rule.Style.Selector.Text()) 13 | // fmt.Println(rule.Style.Styles) 14 | // } 15 | // } 16 | package css 17 | -------------------------------------------------------------------------------- /stylerule.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type CSSStyleRule struct { 9 | Selector *CSSValue 10 | Styles []*CSSStyleDeclaration 11 | } 12 | 13 | func (sr *CSSStyleRule) Text() string { 14 | decls := make([]string, 0, len(sr.Styles)) 15 | 16 | for _, s := range sr.Styles { 17 | decls = append(decls, s.Text()) 18 | } 19 | 20 | return fmt.Sprintf("%s {\n%s\n}", sr.Selector.Text(), strings.Join(decls, ";\n")) 21 | } 22 | -------------------------------------------------------------------------------- /rule_test.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestRuleTypeString(t *testing.T) { 9 | assert.Equal(t, STYLE_RULE.Text(), "") 10 | assert.Equal(t, CHARSET_RULE.Text(), "@charset") 11 | assert.Equal(t, IMPORT_RULE.Text(), "@import") 12 | assert.Equal(t, MEDIA_RULE.Text(), "@media") 13 | assert.Equal(t, FONT_FACE_RULE.Text(), "@font-face") 14 | assert.Equal(t, PAGE_RULE.Text(), "@page") 15 | } 16 | -------------------------------------------------------------------------------- /styledeclaration_test.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestDeclWithImportan(t *testing.T) { 10 | decl := NewCSSStyleDeclaration("width", "100%", true) 11 | assert.Equal(t, decl.Text(), "width: 100% !important") 12 | } 13 | 14 | func TestDeclWithoutImportan(t *testing.T) { 15 | decl := NewCSSStyleDeclaration("width", "100%", false) 16 | assert.Equal(t, decl.Text(), "width: 100%") 17 | } 18 | -------------------------------------------------------------------------------- /stylerule_test.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestStyleRuleText(t *testing.T) { 10 | sr := CSSStyleRule{} 11 | sr.Selector = NewCSSValue(".box") 12 | sr.Styles = make([]*CSSStyleDeclaration, 2) 13 | sr.Styles[0] = NewCSSStyleDeclaration("width", "10px", false) 14 | sr.Styles[1] = NewCSSStyleDeclaration("height", "100px", false) 15 | 16 | assert.Equal(t, sr.Text(), ".box {\nwidth: 10px;\nheight: 100px\n}") 17 | } 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # css 2 | 3 | Package css is for parsing css stylesheet. 4 | 5 | # Document 6 | 7 | [![GoDoc](https://godoc.org/github.com/vanng822/css?status.svg)](https://godoc.org/github.com/vanng822/css) 8 | 9 | # example 10 | 11 | import ( 12 | "github.com/vanng822/css" 13 | "fmt" 14 | ) 15 | func main() { 16 | csstext := "td {width: 100px; height: 100px;}" 17 | ss := css.Parse(csstext) 18 | rules := ss.GetCSSRuleList() 19 | for _, rule := range rules { 20 | fmt.Println(rule.Style.Selector.Text()) 21 | fmt.Println(rule.Style.Styles) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | 13 | -------------------------------------------------------------------------------- /styledeclaration.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type CSSStyleDeclaration struct { 8 | Property string 9 | Value *CSSValue 10 | Important bool 11 | } 12 | 13 | func NewCSSStyleDeclaration(property, value string, important bool) *CSSStyleDeclaration { 14 | return &CSSStyleDeclaration{ 15 | Property: property, 16 | Value: NewCSSValue(value), 17 | Important: important, 18 | } 19 | } 20 | 21 | func (decl *CSSStyleDeclaration) Text() string { 22 | res := fmt.Sprintf("%s: %s", decl.Property, decl.Value.Text()) 23 | if decl.Important { 24 | res += " !important" 25 | } 26 | return res 27 | } 28 | -------------------------------------------------------------------------------- /skip_rule.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import "github.com/gorilla/css/scanner" 4 | 5 | func skipRules(s *scanner.Scanner) { 6 | var ( 7 | open int 8 | close int 9 | started bool 10 | ) 11 | for { 12 | if started && close >= open { 13 | return 14 | } 15 | token := s.Next() 16 | if token.Type == scanner.TokenEOF || token.Type == scanner.TokenError { 17 | return 18 | } 19 | if token.Type == scanner.TokenChar { 20 | if token.Value == "{" { 21 | open++ 22 | started = true 23 | continue 24 | } 25 | if token.Value == "}" { 26 | close++ 27 | started = true 28 | continue 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a golang project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go 3 | 4 | name: Go 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: '1.20' 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test -v ./... 29 | -------------------------------------------------------------------------------- /at_parser_charset_test.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gorilla/css/scanner" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestCharsetDoubleQ(t *testing.T) { 11 | css := Parse(`@charset "UTF-8";`) 12 | 13 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "\"UTF-8\"") 14 | assert.Equal(t, css.CssRuleList[0].Type, CHARSET_RULE) 15 | } 16 | 17 | func TestCharsetSingleQ(t *testing.T) { 18 | css := Parse(`@charset 'iso-8859-15';`) 19 | 20 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "'iso-8859-15'") 21 | assert.Equal(t, css.CssRuleList[0].Type, CHARSET_RULE) 22 | } 23 | 24 | func TestCharsetIgnore(t *testing.T) { 25 | css := parseAtNoBody(scanner.New(` 'iso-8859-15'`), CHARSET_RULE) 26 | 27 | assert.Nil(t, css) 28 | } 29 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 4 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 8 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 11 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 12 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 13 | -------------------------------------------------------------------------------- /rule.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | type RuleType int 4 | 5 | const ( 6 | STYLE_RULE RuleType = iota 7 | CHARSET_RULE 8 | IMPORT_RULE 9 | MEDIA_RULE 10 | FONT_FACE_RULE 11 | PAGE_RULE 12 | KEYFRAMES_RULE 13 | WEBKIT_KEYFRAMES_RULE 14 | COUNTER_STYLE_RULE 15 | ) 16 | 17 | var ruleTypeNames = map[RuleType]string{ 18 | STYLE_RULE: "", 19 | MEDIA_RULE: "@media", 20 | CHARSET_RULE: "@charset", 21 | IMPORT_RULE: "@import", 22 | FONT_FACE_RULE: "@font-face", 23 | PAGE_RULE: "@page", 24 | KEYFRAMES_RULE: "@keyframes", 25 | WEBKIT_KEYFRAMES_RULE: "@-webkit-keyframes", 26 | COUNTER_STYLE_RULE: "@counter-style", 27 | } 28 | 29 | func (rt RuleType) Text() string { 30 | return ruleTypeNames[rt] 31 | } 32 | 33 | type CSSRule struct { 34 | Type RuleType 35 | Style CSSStyleRule 36 | Rules []*CSSRule 37 | } 38 | 39 | func NewRule(ruleType RuleType) *CSSRule { 40 | r := &CSSRule{ 41 | Type: ruleType, 42 | } 43 | r.Style.Styles = make([]*CSSStyleDeclaration, 0) 44 | r.Rules = make([]*CSSRule, 0) 45 | return r 46 | } 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Nguyen Van Nhu 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /at_parser.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "github.com/gorilla/css/scanner" 5 | ) 6 | 7 | func parseAtNoBody(s *scanner.Scanner, ruleType RuleType) *CSSRule { 8 | /* 9 | 10 | Syntax: 11 | @charset charset; 12 | 13 | Example: 14 | @charset "UTF-8"; 15 | 16 | 17 | Syntax: 18 | @import url; or 19 | @import url list-of-media-queries; 20 | 21 | Example: 22 | @import url("fineprint.css") print; 23 | @import url("bluish.css") projection, tv; 24 | @import 'custom.css'; 25 | @import url("chrome://communicator/skin/"); 26 | @import "common.css" screen, projection; 27 | @import url('landscape.css') screen and (orientation:landscape); 28 | 29 | */ 30 | 31 | parsed := make([]*scanner.Token, 0) 32 | Loop: 33 | for { 34 | token := s.Next() 35 | 36 | if token.Type == scanner.TokenEOF || token.Type == scanner.TokenError { 37 | return nil 38 | } 39 | // take everything for now 40 | switch token.Type { 41 | case scanner.TokenEOF, scanner.TokenError: 42 | break Loop 43 | case scanner.TokenChar: 44 | if token.Value == ";" { 45 | break Loop 46 | } 47 | fallthrough 48 | default: 49 | parsed = append(parsed, token) 50 | } 51 | } 52 | 53 | rule := NewRule(ruleType) 54 | rule.Style.Selector = &CSSValue{Tokens: parsed} 55 | return rule 56 | } 57 | -------------------------------------------------------------------------------- /value_test.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gorilla/css/scanner" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestCSSValue(t *testing.T) { 11 | for _, c := range []struct{ inp, out string }{ 12 | {`"identifier"`, `identifier`}, 13 | {`identifier/path`, `identifier/path`}, 14 | {`"with \' \" \\ chars"`, `with ' " \ chars`}, 15 | } { 16 | t.Run(c.inp, func(t *testing.T) { 17 | val := NewCSSValue(c.inp) 18 | assert.Equal(t, val.Text(), c.inp) 19 | assert.Equal(t, val.ParsedText(), c.out) 20 | }) 21 | } 22 | } 23 | 24 | func TestNewCSSValueString(t *testing.T) { 25 | for _, c := range []struct{ inp, out string }{ 26 | {`identifier`, `"identifier"`}, 27 | {`with special ' \ " char`, `"with special ' \\ \" char"`}, 28 | } { 29 | t.Run(c.inp, func(t *testing.T) { 30 | val := NewCSSValueString(c.inp) 31 | assert.Equal(t, val.Text(), c.out) 32 | assert.Equal(t, val.ParsedText(), c.inp) 33 | }) 34 | } 35 | } 36 | 37 | func TestSplitOnToken(t *testing.T) { 38 | val := NewCSSValue(`monospace, font-name, "font3",sans-serif`) 39 | split := val.SplitOnToken(&scanner.Token{Type: scanner.TokenChar, Value: ","}) 40 | 41 | assert.Equal(t, len(split), 4) 42 | assert.Equal(t, split[0].ParsedText(), "monospace") 43 | assert.Equal(t, split[1].ParsedText(), "font-name") 44 | assert.Equal(t, split[2].ParsedText(), "font3") 45 | assert.Equal(t, split[3].ParsedText(), "sans-serif") 46 | } 47 | -------------------------------------------------------------------------------- /block_parser_test.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestParseBlock(t *testing.T) { 10 | css := ParseBlock(` 11 | font-family: "Source Sans Pro", Arial, sans-serif; 12 | font-size: 27px; 13 | line-height: 35px;`) 14 | 15 | assert.Equal(t, len(css), 3) 16 | assert.Equal(t, "35px", css[2].Value.Text()) 17 | } 18 | 19 | func TestParseBlockOneLine(t *testing.T) { 20 | css := ParseBlock("font-family: \"Source Sans Pro\", Arial, sans-serif; font-size: 27px;") 21 | 22 | assert.Equal(t, len(css), 2) 23 | assert.Equal(t, "27px", css[1].Value.Text()) 24 | assert.Equal(t, "\"Source Sans Pro\", Arial, sans-serif", css[0].Value.Text()) 25 | } 26 | 27 | func TestParseBlockBlankEnd(t *testing.T) { 28 | css := ParseBlock("font-size: 27px; width: 10px") 29 | 30 | assert.Equal(t, len(css), 2) 31 | assert.Equal(t, "27px", css[0].Value.Text()) 32 | assert.Equal(t, "10px", css[1].Value.Text()) 33 | } 34 | 35 | func TestParseBlockInportant(t *testing.T) { 36 | css := ParseBlock("font-size: 27px; width: 10px !important") 37 | 38 | assert.Equal(t, len(css), 2) 39 | assert.Equal(t, "27px", css[0].Value.Text()) 40 | assert.Equal(t, "10px", css[1].Value.Text()) 41 | assert.Equal(t, true, css[1].Important) 42 | } 43 | 44 | func TestParseBlockWithBraces(t *testing.T) { 45 | css := ParseBlock("{ font-size: 27px; width: 10px }") 46 | 47 | assert.Equal(t, len(css), 2) 48 | assert.Equal(t, "27px", css[0].Value.Text()) 49 | assert.Equal(t, "10px", css[1].Value.Text()) 50 | } 51 | -------------------------------------------------------------------------------- /at_parser_import_test.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gorilla/css/scanner" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestImport(t *testing.T) { 11 | css := Parse(`@import url("fineprint.css") print; 12 | @import url("bluish.css") projection, tv; 13 | @import 'custom.css'; 14 | @import url("chrome://communicator/skin/"); 15 | @import "common.css" screen, projection; 16 | @import url('landscape.css') screen and (orientation:landscape);`) 17 | 18 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "url(\"fineprint.css\") print") 19 | assert.Equal(t, css.CssRuleList[1].Style.Selector.Text(), "url(\"bluish.css\") projection, tv") 20 | assert.Equal(t, css.CssRuleList[2].Style.Selector.Text(), "'custom.css'") 21 | assert.Equal(t, css.CssRuleList[3].Style.Selector.Text(), "url(\"chrome://communicator/skin/\")") 22 | assert.Equal(t, css.CssRuleList[4].Style.Selector.Text(), "\"common.css\" screen, projection") 23 | assert.Equal(t, css.CssRuleList[5].Style.Selector.Text(), "url('landscape.css') screen and (orientation:landscape)") 24 | 25 | assert.Equal(t, css.CssRuleList[0].Type, IMPORT_RULE) 26 | assert.Equal(t, css.CssRuleList[1].Type, IMPORT_RULE) 27 | assert.Equal(t, css.CssRuleList[2].Type, IMPORT_RULE) 28 | assert.Equal(t, css.CssRuleList[3].Type, IMPORT_RULE) 29 | assert.Equal(t, css.CssRuleList[4].Type, IMPORT_RULE) 30 | assert.Equal(t, css.CssRuleList[5].Type, IMPORT_RULE) 31 | } 32 | 33 | func TestImportIgnore(t *testing.T) { 34 | css := parseAtNoBody(scanner.New(` url("fineprint.css") print`), IMPORT_RULE) 35 | assert.Nil(t, css) 36 | } 37 | -------------------------------------------------------------------------------- /selector_parser.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "github.com/gorilla/css/scanner" 5 | ) 6 | 7 | func parseSelector(s *scanner.Scanner) []*scanner.Token { 8 | /* 9 | selector : any+; 10 | any : [ IDENT | NUMBER | PERCENTAGE | DIMENSION | STRING 11 | | DELIM | URI | HASH | UNICODE-RANGE | INCLUDES 12 | | DASHMATCH | ':' | FUNCTION S* [any|unused]* ')' 13 | | '(' S* [any|unused]* ')' | '[' S* [any|unused]* ']' 14 | ] S*; 15 | */ 16 | 17 | result := make([]*scanner.Token, 0) 18 | 19 | Loop: 20 | for { 21 | token := s.Next() 22 | 23 | switch token.Type { 24 | case scanner.TokenError, scanner.TokenEOF: 25 | break Loop 26 | case scanner.TokenChar: 27 | if token.Value == "{" { 28 | break Loop 29 | } 30 | fallthrough 31 | case scanner.TokenIdent: 32 | fallthrough 33 | case scanner.TokenS: 34 | fallthrough 35 | case scanner.TokenNumber: 36 | fallthrough 37 | case scanner.TokenPercentage: 38 | fallthrough 39 | case scanner.TokenDimension: 40 | fallthrough 41 | case scanner.TokenString: 42 | fallthrough 43 | case scanner.TokenURI: 44 | fallthrough 45 | case scanner.TokenHash: 46 | fallthrough 47 | case scanner.TokenUnicodeRange: 48 | fallthrough 49 | case scanner.TokenIncludes: 50 | fallthrough 51 | case scanner.TokenDashMatch: 52 | fallthrough 53 | case scanner.TokenFunction: 54 | fallthrough 55 | case scanner.TokenSuffixMatch: 56 | fallthrough 57 | case scanner.TokenPrefixMatch: 58 | fallthrough 59 | case scanner.TokenSubstringMatch: 60 | result = append(result, token) 61 | } 62 | } 63 | 64 | return result 65 | } 66 | -------------------------------------------------------------------------------- /value.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/gorilla/css/scanner" 7 | ) 8 | 9 | type CSSValue struct { 10 | Tokens []*scanner.Token 11 | } 12 | 13 | func NewCSSValue(csstext string) *CSSValue { 14 | sc := scanner.New(csstext) 15 | val := CSSValue{Tokens: make([]*scanner.Token, 0)} 16 | Loop: 17 | for { 18 | token := sc.Next() 19 | switch token.Type { 20 | case scanner.TokenError, scanner.TokenEOF: 21 | break Loop 22 | default: 23 | val.Tokens = append(val.Tokens, token) 24 | } 25 | } 26 | return &val 27 | } 28 | 29 | func NewCSSValueString(data string) *CSSValue { 30 | data = strings.ReplaceAll(data, `\`, `\\`) 31 | data = strings.ReplaceAll(data, `"`, `\"`) 32 | data = `"` + data + `"` 33 | token := scanner.Token{scanner.TokenString, data, 0, 0} 34 | return &CSSValue{Tokens: []*scanner.Token{&token}} 35 | } 36 | 37 | func (v *CSSValue) SplitOnToken(split *scanner.Token) []*CSSValue { 38 | res := make([]*CSSValue, 0) 39 | current := make([]*scanner.Token, 0) 40 | for _, tok := range v.Tokens { 41 | if tok.Type == split.Type && tok.Value == split.Value { 42 | res = append(res, &CSSValue{Tokens: current}) 43 | current = make([]*scanner.Token, 0) 44 | } else { 45 | current = append(current, tok) 46 | } 47 | } 48 | res = append(res, &CSSValue{Tokens: current}) 49 | return res 50 | } 51 | 52 | func (v *CSSValue) Text() string { 53 | var b strings.Builder 54 | for _, t := range v.Tokens { 55 | b.WriteString(t.Value) 56 | } 57 | return strings.TrimSpace(b.String()) 58 | } 59 | 60 | func (v *CSSValue) ParsedText() string { 61 | var b strings.Builder 62 | for _, t := range v.Tokens { 63 | switch t.Type { 64 | case scanner.TokenString: 65 | val := t.Value[1 : len(t.Value)-1] // remove trailing / leading quotes 66 | val = strings.ReplaceAll(val, `\"`, `"`) 67 | val = strings.ReplaceAll(val, `\'`, `'`) 68 | val = strings.ReplaceAll(val, `\\`, `\`) 69 | // \A9 should be replaced by the corresponding rune 70 | b.WriteString(val) 71 | default: 72 | b.WriteString(t.Value) 73 | } 74 | } 75 | return strings.TrimSpace(b.String()) 76 | } 77 | -------------------------------------------------------------------------------- /parser_keyframes_test.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestKeyFrames(t *testing.T) { 10 | css := Parse(`@keyframes slidein { 11 | from { 12 | margin-left: 100%; 13 | width: 300%; 14 | } 15 | 16 | to { 17 | margin-left: 0%; 18 | width: 100%; 19 | } 20 | }`) 21 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "slidein") 22 | assert.Equal(t, css.CssRuleList[0].Type, KEYFRAMES_RULE) 23 | assert.Equal(t, len(css.CssRuleList[0].Rules), 2) 24 | assert.Equal(t, css.CssRuleList[0].Rules[0].Style.Selector.Text(), "from") 25 | assert.Equal(t, css.CssRuleList[0].Rules[0].Style.Styles[0].Value.Text(), "100%") 26 | assert.Equal(t, css.CssRuleList[0].Rules[0].Style.Styles[1].Value.Text(), "300%") 27 | assert.Equal(t, css.CssRuleList[0].Rules[1].Style.Selector.Text(), "to") 28 | } 29 | 30 | func TestKeyFramesPercent(t *testing.T) { 31 | css := Parse(`@keyframes identifier { 32 | 0% { top: 0; } 33 | 50% { top: 30px; left: 20px; } 34 | 50% { top: 10px; } 35 | 100% { top: 0; } 36 | }`) 37 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "identifier") 38 | assert.Equal(t, css.CssRuleList[0].Type, KEYFRAMES_RULE) 39 | assert.Equal(t, len(css.CssRuleList[0].Rules), 4) 40 | assert.Equal(t, css.CssRuleList[0].Rules[0].Style.Selector.Text(), "0%") 41 | assert.Equal(t, css.CssRuleList[0].Rules[0].Style.Styles[0].Value.Text(), "0") 42 | assert.Equal(t, css.CssRuleList[0].Rules[1].Style.Selector.Text(), "50%") 43 | assert.Equal(t, css.CssRuleList[0].Rules[2].Style.Selector.Text(), "50%") 44 | assert.Equal(t, css.CssRuleList[0].Rules[3].Style.Selector.Text(), "100%") 45 | } 46 | 47 | func TestWebKitKeyFramesPercent(t *testing.T) { 48 | css := Parse(`@-webkit-keyframes identifier { 49 | 0% { top: 0; } 50 | 50% { top: 30px; left: 20px; } 51 | 50% { top: 10px; } 52 | 100% { top: 0; } 53 | }`) 54 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "identifier") 55 | assert.Equal(t, css.CssRuleList[0].Type, WEBKIT_KEYFRAMES_RULE) 56 | assert.Equal(t, len(css.CssRuleList[0].Rules), 4) 57 | assert.Equal(t, css.CssRuleList[0].Rules[0].Style.Selector.Text(), "0%") 58 | assert.Equal(t, css.CssRuleList[0].Rules[0].Style.Styles[0].Value.Text(), "0") 59 | assert.Equal(t, css.CssRuleList[0].Rules[1].Style.Selector.Text(), "50%") 60 | assert.Equal(t, css.CssRuleList[0].Rules[2].Style.Selector.Text(), "50%") 61 | assert.Equal(t, css.CssRuleList[0].Rules[3].Style.Selector.Text(), "100%") 62 | } 63 | -------------------------------------------------------------------------------- /block_parser.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "github.com/gorilla/css/scanner" 5 | ) 6 | 7 | type blockParserContext struct { 8 | State State 9 | NowProperty string 10 | NowValue []*scanner.Token 11 | NowImportant bool 12 | } 13 | 14 | func (context *blockParserContext) extractDeclaration() *CSSStyleDeclaration { 15 | decl := CSSStyleDeclaration{ 16 | Property: context.NowProperty, 17 | Value: &CSSValue{Tokens: context.NowValue}, 18 | Important: context.NowImportant, 19 | } 20 | context.NowProperty = "" 21 | context.NowValue = make([]*scanner.Token, 0) 22 | context.NowImportant = false 23 | return &decl 24 | } 25 | 26 | // ParseBlock take a string of a css block, 27 | // parses it and returns a map of css style declarations. 28 | func ParseBlock(csstext string) []*CSSStyleDeclaration { 29 | s := scanner.New(csstext) 30 | return parseBlock(s) 31 | } 32 | 33 | func parseBlock(s *scanner.Scanner) []*CSSStyleDeclaration { 34 | /* block : '{' S* [ any | block | ATKEYWORD S* | ';' S* ]* '}' S*; 35 | property : IDENT; 36 | value : [ any | block | ATKEYWORD S* ]+; 37 | any : [ IDENT | NUMBER | PERCENTAGE | DIMENSION | STRING 38 | | DELIM | URI | HASH | UNICODE-RANGE | INCLUDES 39 | | DASHMATCH | ':' | FUNCTION S* [any|unused]* ')' 40 | | '(' S* [any|unused]* ')' | '[' S* [any|unused]* ']' 41 | ] S*; 42 | */ 43 | decls := make([]*CSSStyleDeclaration, 0) 44 | 45 | context := &blockParserContext{ 46 | State: STATE_NONE, 47 | NowProperty: "", 48 | NowValue: make([]*scanner.Token, 0), 49 | NowImportant: false, 50 | } 51 | 52 | for { 53 | token := s.Next() 54 | 55 | //fmt.Printf("BLOCK(%d): %s:'%s'\n", context.State, token.Type.String(), token.Value) 56 | 57 | if token.Type == scanner.TokenError { 58 | break 59 | } 60 | 61 | if token.Type == scanner.TokenEOF { 62 | if context.State == STATE_VALUE { 63 | // we are ending without ; or } 64 | // this can happen when we parse only css declaration 65 | decl := context.extractDeclaration() 66 | decls = append(decls, decl) 67 | } 68 | break 69 | } 70 | 71 | switch token.Type { 72 | 73 | case scanner.TokenS: 74 | if context.State == STATE_VALUE { 75 | context.NowValue = append(context.NowValue, token) 76 | } 77 | case scanner.TokenIdent: 78 | if context.State == STATE_NONE { 79 | context.State = STATE_PROPERTY 80 | context.NowProperty += token.Value 81 | break 82 | } 83 | if token.Value == "important" { 84 | context.NowImportant = true 85 | } else { 86 | context.NowValue = append(context.NowValue, token) 87 | } 88 | case scanner.TokenChar: 89 | if context.State == STATE_NONE { 90 | if token.Value == "{" { 91 | break 92 | } 93 | } 94 | if context.State == STATE_PROPERTY { 95 | if token.Value == ":" { 96 | context.State = STATE_VALUE 97 | } 98 | // CHAR and STATE_PROPERTY but not : then weird 99 | // break to ignore it 100 | break 101 | } 102 | // should be no state or value 103 | if token.Value == ";" { 104 | decl := context.extractDeclaration() 105 | decls = append(decls, decl) 106 | context.State = STATE_NONE 107 | } else if token.Value == "}" { // last property in a block can have optional ; 108 | if context.State == STATE_VALUE { 109 | // only valid if state is still VALUE, could be ;} 110 | decl := context.extractDeclaration() 111 | decls = append(decls, decl) 112 | } 113 | // we are done 114 | return decls 115 | } else if token.Value != "!" { 116 | context.NowValue = append(context.NowValue, token) 117 | } 118 | break 119 | 120 | // any 121 | case scanner.TokenNumber: 122 | fallthrough 123 | case scanner.TokenPercentage: 124 | fallthrough 125 | case scanner.TokenDimension: 126 | fallthrough 127 | case scanner.TokenString: 128 | fallthrough 129 | case scanner.TokenURI: 130 | fallthrough 131 | case scanner.TokenHash: 132 | fallthrough 133 | case scanner.TokenUnicodeRange: 134 | fallthrough 135 | case scanner.TokenIncludes: 136 | fallthrough 137 | case scanner.TokenDashMatch: 138 | fallthrough 139 | case scanner.TokenFunction: 140 | fallthrough 141 | case scanner.TokenSubstringMatch: 142 | context.NowValue = append(context.NowValue, token) 143 | } 144 | } 145 | 146 | return decls 147 | } 148 | -------------------------------------------------------------------------------- /parser_media_test.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestMedia(t *testing.T) { 10 | css := Parse(`@media only screen and (max-width: 600px) { 11 | table[class="body"] img { 12 | width: auto !important; 13 | height: auto !important 14 | } 15 | table[class="body"] center { 16 | min-width: 0 !important 17 | } 18 | table[class="body"] .container { 19 | width: 95% !important 20 | } 21 | table[class="body"] .row { 22 | width: 100% !important; 23 | display: block !important 24 | } 25 | }`) 26 | 27 | //fmt.Println(css) 28 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "only screen and (max-width: 600px)") 29 | assert.Equal(t, css.CssRuleList[0].Type, MEDIA_RULE) 30 | assert.Equal(t, len(css.CssRuleList[0].Rules), 4) 31 | assert.Equal(t, css.CssRuleList[0].Rules[0].Style.Selector.Text(), "table[class=\"body\"] img") 32 | assert.Equal(t, css.CssRuleList[0].Rules[0].Style.Styles[0].Value.Text(), "auto") 33 | assert.Equal(t, css.CssRuleList[0].Rules[0].Style.Styles[1].Important, true) 34 | assert.Equal(t, css.CssRuleList[0].Rules[1].Style.Selector.Text(), "table[class=\"body\"] center") 35 | assert.Equal(t, css.CssRuleList[0].Rules[2].Style.Selector.Text(), "table[class=\"body\"] .container") 36 | assert.Equal(t, css.CssRuleList[0].Rules[3].Style.Selector.Text(), "table[class=\"body\"] .row") 37 | 38 | } 39 | 40 | func TestMediaMulti(t *testing.T) { 41 | css := Parse(` 42 | table.one { 43 | width: 30px; 44 | } 45 | @media only screen and (max-width: 600px) { 46 | table[class="body"] img { 47 | width: auto !important; 48 | height: auto !important 49 | } 50 | table[class="body"] center { 51 | min-width: 0 !important 52 | } 53 | table[class="body"] .container { 54 | width: 95% !important 55 | } 56 | table[class="body"] .row { 57 | width: 100% !important; 58 | display: block !important 59 | } 60 | } 61 | @media all and (min-width: 48em) { 62 | blockquote { 63 | font-size: 34px; 64 | line-height: 40px; 65 | padding-top: 2px; 66 | padding-bottom: 3px; 67 | } 68 | } 69 | table.two { 70 | width: 80px; 71 | }`) 72 | 73 | assert.Equal(t, len(css.CssRuleList), 4) 74 | 75 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "table.one") 76 | assert.Equal(t, css.CssRuleList[0].Type, STYLE_RULE) 77 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Value.Text(), "30px") 78 | assert.Equal(t, len(css.CssRuleList[0].Rules), 0) 79 | 80 | assert.Equal(t, css.CssRuleList[1].Style.Selector.Text(), "only screen and (max-width: 600px)") 81 | assert.Equal(t, css.CssRuleList[1].Type, MEDIA_RULE) 82 | assert.Equal(t, len(css.CssRuleList[1].Rules), 4) 83 | assert.Equal(t, css.CssRuleList[1].Rules[0].Style.Selector.Text(), "table[class=\"body\"] img") 84 | assert.Equal(t, css.CssRuleList[1].Rules[0].Style.Styles[1].Value.Text(), "auto") 85 | assert.Equal(t, css.CssRuleList[1].Rules[0].Style.Styles[1].Important, true) 86 | assert.Equal(t, css.CssRuleList[1].Rules[1].Style.Selector.Text(), "table[class=\"body\"] center") 87 | assert.Equal(t, css.CssRuleList[1].Rules[2].Style.Selector.Text(), "table[class=\"body\"] .container") 88 | assert.Equal(t, css.CssRuleList[1].Rules[3].Style.Selector.Text(), "table[class=\"body\"] .row") 89 | 90 | assert.Equal(t, css.CssRuleList[2].Style.Selector.Text(), "all and (min-width: 48em)") 91 | assert.Equal(t, css.CssRuleList[2].Type, MEDIA_RULE) 92 | assert.Equal(t, css.CssRuleList[2].Rules[0].Style.Selector.Text(), "blockquote") 93 | assert.Equal(t, css.CssRuleList[2].Rules[0].Style.Styles[0].Value.Text(), "34px") 94 | assert.Equal(t, css.CssRuleList[2].Rules[0].Style.Styles[1].Value.Text(), "40px") 95 | assert.Equal(t, css.CssRuleList[2].Rules[0].Style.Styles[2].Value.Text(), "2px") 96 | assert.Equal(t, css.CssRuleList[2].Rules[0].Style.Styles[3].Value.Text(), "3px") 97 | assert.Equal(t, len(css.CssRuleList[2].Rules), 1) 98 | 99 | assert.Equal(t, css.CssRuleList[3].Style.Selector.Text(), "table.two") 100 | assert.Equal(t, css.CssRuleList[3].Type, STYLE_RULE) 101 | assert.Equal(t, css.CssRuleList[3].Style.Styles[0].Value.Text(), "80px") 102 | assert.Equal(t, len(css.CssRuleList[3].Rules), 0) 103 | } 104 | -------------------------------------------------------------------------------- /parser_test.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestWithoutImpotant(t *testing.T) { 10 | css := Parse(`div .a { font-size: 150%;}`) 11 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Value.Text(), "150%") 12 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Property, "font-size") 13 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Important, false) 14 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "div .a") 15 | 16 | } 17 | 18 | func TestWithImpotant(t *testing.T) { 19 | css := Parse("div .a { font-size: 150% !important;}") 20 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Value.Text(), "150%") 21 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Property, "font-size") 22 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Important, true) 23 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "div .a") 24 | } 25 | 26 | func TestMultipleDeclarations(t *testing.T) { 27 | css := Parse(`div .a { 28 | font-size: 150%; 29 | width: 100% 30 | }`) 31 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Value.Text(), "150%") 32 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Property, "font-size") 33 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Important, false) 34 | assert.Equal(t, css.CssRuleList[0].Style.Styles[1].Value.Text(), "100%") 35 | assert.Equal(t, css.CssRuleList[0].Style.Styles[1].Property, "width") 36 | assert.Equal(t, css.CssRuleList[0].Style.Styles[1].Important, false) 37 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "div .a") 38 | } 39 | 40 | func TestValuePx(t *testing.T) { 41 | css := Parse("div .a { font-size: 45px;}") 42 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Value.Text(), "45px") 43 | } 44 | 45 | func TestValueEm(t *testing.T) { 46 | css := Parse("div .a { font-size: 45em;}") 47 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Value.Text(), "45em") 48 | } 49 | 50 | func TestValueHex(t *testing.T) { 51 | css := Parse("div .a { color: #123456;}") 52 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Value.Text(), "#123456") 53 | } 54 | 55 | func TestValueRGBFunction(t *testing.T) { 56 | css := Parse(".color{ color: rgb(1,2,3);}") 57 | 58 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Value.Text(), "rgb(1,2,3)") 59 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), ".color") 60 | } 61 | 62 | func TestValueString(t *testing.T) { 63 | css := Parse("div .center { text-align: center; }") 64 | 65 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Value.Text(), "center") 66 | } 67 | 68 | func TestValueWhiteSpace(t *testing.T) { 69 | css := Parse(".div { padding: 10px 0 0 10px}") 70 | 71 | assert.Equal(t, "10px 0 0 10px", css.CssRuleList[0].Style.Styles[0].Value.Text()) 72 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), ".div") 73 | } 74 | 75 | func TestValueMixed(t *testing.T) { 76 | css := Parse(`td { 77 | padding: 0 12px 0 10px; 78 | border-right: 1px solid white 79 | }`) 80 | 81 | assert.Equal(t, "0 12px 0 10px", css.CssRuleList[0].Style.Styles[0].Value.Text()) 82 | assert.Equal(t, "1px solid white", css.CssRuleList[0].Style.Styles[1].Value.Text()) 83 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "td") 84 | } 85 | 86 | func TestQuoteValue(t *testing.T) { 87 | css := Parse(`blockquote { 88 | font-family: "Source Sans Pro", Arial, sans-serif; 89 | font-size: 27px; 90 | line-height: 35px;}`) 91 | 92 | assert.Equal(t, "\"Source Sans Pro\", Arial, sans-serif", css.CssRuleList[0].Style.Styles[0].Value.Text()) 93 | assert.Equal(t, "27px", css.CssRuleList[0].Style.Styles[1].Value.Text()) 94 | assert.Equal(t, "35px", css.CssRuleList[0].Style.Styles[2].Value.Text()) 95 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "blockquote") 96 | } 97 | 98 | func TestDashClassname(t *testing.T) { 99 | css := Parse(`.content { 100 | padding: 0px; 101 | } 102 | .content-wrap { 103 | padding: 2px; 104 | }`) 105 | 106 | assert.Equal(t, ".content", css.CssRuleList[0].Style.Selector.Text()) 107 | assert.Equal(t, ".content-wrap", css.CssRuleList[1].Style.Selector.Text()) 108 | assert.Equal(t, "0px", css.CssRuleList[0].Style.Styles[0].Value.Text()) 109 | assert.Equal(t, "2px", css.CssRuleList[1].Style.Styles[0].Value.Text()) 110 | } 111 | 112 | func TestNotSupportedAtRule(t *testing.T) { 113 | rules := []string{ 114 | `@namespace url(http://www.w3.org/1999/xhtml);`, 115 | `@document url(http://www.w3.org/), 116 | url-prefix(http://www.w3.org/Style/), 117 | domain(mozilla.org), 118 | regexp("https:.*") 119 | { 120 | 121 | body { color: purple; background: yellow; } 122 | }`, 123 | } 124 | css := &CSSStyleSheet{} 125 | css.CssRuleList = make([]*CSSRule, 0) 126 | for _, rule := range rules { 127 | assert.Equal(t, css, Parse(rule)) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/gorilla/css/scanner" 7 | ) 8 | 9 | /* 10 | stylesheet : [ CDO | CDC | S | statement ]*; 11 | statement : ruleset | at-rule; 12 | at-rule : ATKEYWORD S* any* [ block | ';' S* ]; 13 | block : '{' S* [ any | block | ATKEYWORD S* | ';' S* ]* '}' S*; 14 | ruleset : selector? '{' S* declaration? [ ';' S* declaration? ]* '}' S*; 15 | selector : any+; 16 | declaration : property S* ':' S* value; 17 | property : IDENT; 18 | value : [ any | block | ATKEYWORD S* ]+; 19 | any : [ IDENT | NUMBER | PERCENTAGE | DIMENSION | STRING 20 | | DELIM | URI | HASH | UNICODE-RANGE | INCLUDES 21 | | DASHMATCH | ':' | FUNCTION S* [any|unused]* ')' 22 | | '(' S* [any|unused]* ')' | '[' S* [any|unused]* ']' 23 | ] S*; 24 | unused : block | ATKEYWORD S* | ';' S* | CDO S* | CDC S*; 25 | */ 26 | 27 | type State int 28 | 29 | const ( 30 | STATE_NONE State = iota 31 | STATE_SELECTOR 32 | STATE_PROPERTY 33 | STATE_VALUE 34 | ) 35 | 36 | type parserContext struct { 37 | State State 38 | NowSelector []*scanner.Token 39 | NowRuleType RuleType 40 | CurrentNestedRule *CSSRule 41 | } 42 | 43 | func (context *parserContext) resetContextStyleRule() { 44 | context.NowSelector = make([]*scanner.Token, 0) 45 | context.NowRuleType = STYLE_RULE 46 | context.State = STATE_NONE 47 | } 48 | 49 | func parseRule(context *parserContext, s *scanner.Scanner, css *CSSStyleSheet) { 50 | rule := NewRule(context.NowRuleType) 51 | selector := append(context.NowSelector, parseSelector(s)...) 52 | rule.Style.Selector = &CSSValue{Tokens: selector} 53 | rule.Style.Styles = parseBlock(s) 54 | if context.CurrentNestedRule != nil { 55 | context.CurrentNestedRule.Rules = append(context.CurrentNestedRule.Rules, rule) 56 | } else { 57 | css.CssRuleList = append(css.CssRuleList, rule) 58 | } 59 | context.resetContextStyleRule() 60 | } 61 | 62 | // Parse takes a string of valid css rules, stylesheet, 63 | // and parses it. Be aware this function has poor error handling 64 | // so you should have valid syntax in your css 65 | func Parse(csstext string) *CSSStyleSheet { 66 | context := &parserContext{ 67 | State: STATE_NONE, 68 | NowSelector: make([]*scanner.Token, 0), 69 | NowRuleType: STYLE_RULE, 70 | CurrentNestedRule: nil, 71 | } 72 | 73 | css := &CSSStyleSheet{} 74 | css.CssRuleList = make([]*CSSRule, 0) 75 | s := scanner.New(csstext) 76 | 77 | for { 78 | token := s.Next() 79 | 80 | if token.Type == scanner.TokenEOF || token.Type == scanner.TokenError { 81 | break 82 | } 83 | 84 | switch token.Type { 85 | case scanner.TokenCDO: 86 | break 87 | case scanner.TokenCDC: 88 | break 89 | case scanner.TokenComment: 90 | break 91 | case scanner.TokenS: 92 | break 93 | case scanner.TokenAtKeyword: 94 | switch token.Value { 95 | case "@media": 96 | context.NowRuleType = MEDIA_RULE 97 | case "@font-face": 98 | // Parse as normal rule, would be nice to parse according to syntax 99 | // https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face 100 | context.NowRuleType = FONT_FACE_RULE 101 | parseRule(context, s, css) 102 | case "@import": 103 | // No validation 104 | // https://developer.mozilla.org/en-US/docs/Web/CSS/@import 105 | rule := parseAtNoBody(s, IMPORT_RULE) 106 | if rule != nil { 107 | css.CssRuleList = append(css.CssRuleList, rule) 108 | } 109 | context.resetContextStyleRule() 110 | case "@charset": 111 | // No validation 112 | // https://developer.mozilla.org/en-US/docs/Web/CSS/@charset 113 | rule := parseAtNoBody(s, CHARSET_RULE) 114 | if rule != nil { 115 | css.CssRuleList = append(css.CssRuleList, rule) 116 | } 117 | context.resetContextStyleRule() 118 | 119 | case "@page": 120 | context.NowRuleType = PAGE_RULE 121 | parseRule(context, s, css) 122 | case "@keyframes": 123 | context.NowRuleType = KEYFRAMES_RULE 124 | case "@-webkit-keyframes": 125 | context.NowRuleType = WEBKIT_KEYFRAMES_RULE 126 | case "@counter-style": 127 | context.NowRuleType = COUNTER_STYLE_RULE 128 | parseRule(context, s, css) 129 | default: 130 | log.Printf("Skip unsupported atrule: %s", token.Value) 131 | skipRules(s) 132 | context.resetContextStyleRule() 133 | } 134 | default: 135 | if context.State == STATE_NONE { 136 | if token.Value == "}" && context.CurrentNestedRule != nil { 137 | // close media/keyframe/… rule 138 | css.CssRuleList = append(css.CssRuleList, context.CurrentNestedRule) 139 | context.CurrentNestedRule = nil 140 | break 141 | } 142 | } 143 | 144 | if context.NowRuleType == MEDIA_RULE || context.NowRuleType == KEYFRAMES_RULE || context.NowRuleType == WEBKIT_KEYFRAMES_RULE { 145 | context.CurrentNestedRule = NewRule(context.NowRuleType) 146 | sel := append([]*scanner.Token{token}, parseSelector(s)...) 147 | context.CurrentNestedRule.Style.Selector = &CSSValue{Tokens: sel} 148 | context.resetContextStyleRule() 149 | break 150 | } else { 151 | context.NowSelector = append(context.NowSelector, token) 152 | parseRule(context, s, css) 153 | break 154 | } 155 | } 156 | } 157 | return css 158 | } 159 | -------------------------------------------------------------------------------- /parser_selector_test.go: -------------------------------------------------------------------------------- 1 | package css 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestMultipleSelectors(t *testing.T) { 11 | css := Parse(`div .a { 12 | font-size: 150%; 13 | } 14 | p .b { 15 | font-size: 250%; 16 | }`) 17 | 18 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "div .a") 19 | assert.Equal(t, css.CssRuleList[1].Style.Selector.Text(), "p .b") 20 | 21 | } 22 | 23 | func TestIdSelector(t *testing.T) { 24 | css := Parse("#div { color: red;}") 25 | 26 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Value.Text(), "red") 27 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "#div") 28 | } 29 | 30 | func TestClassSelector(t *testing.T) { 31 | css := Parse(".div { color: green;}") 32 | 33 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Value.Text(), "green") 34 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), ".div") 35 | } 36 | 37 | func TestStarSelector(t *testing.T) { 38 | css := Parse("* { text-rendering: optimizelegibility; }") 39 | 40 | assert.Equal(t, "optimizelegibility", css.CssRuleList[0].Style.Styles[0].Value.Text()) 41 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "*") 42 | } 43 | 44 | func TestStarSelectorMulti(t *testing.T) { 45 | css := Parse(`div .a { 46 | font-size: 150%; 47 | } 48 | * { text-rendering: optimizelegibility; }`) 49 | 50 | assert.Equal(t, "150%", css.CssRuleList[0].Style.Styles[0].Value.Text()) 51 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "div .a") 52 | 53 | assert.Equal(t, "optimizelegibility", css.CssRuleList[1].Style.Styles[0].Value.Text()) 54 | assert.Equal(t, css.CssRuleList[1].Style.Selector.Text(), "*") 55 | } 56 | 57 | func TestMixedClassSelectors(t *testing.T) { 58 | selectors := []string{".footer__content_wrapper--last", 59 | "table[class=\"body\"] .footer__content td", 60 | "table[class=\"body\"] td.footer__link_wrapper--first", 61 | "table[class=\"body\"] td.footer__link_wrapper--last"} 62 | 63 | for _, selector := range selectors { 64 | css := Parse(fmt.Sprintf(` %s { 65 | border-collapse: separate; 66 | padding: 10px 0 0 67 | }`, selector)) 68 | 69 | assert.Equal(t, "separate", css.CssRuleList[0].Style.Styles[0].Value.Text()) 70 | assert.Equal(t, "10px 0 0", css.CssRuleList[0].Style.Styles[1].Value.Text()) 71 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), selector) 72 | } 73 | } 74 | 75 | func TestGenericSelectors(t *testing.T) { 76 | selectors := []string{ 77 | "p ~ ul", 78 | "div > p", 79 | "div > p", 80 | "div p", 81 | "div, p", 82 | "[target]", 83 | "[target=_blank]", 84 | "[title~=flower]", 85 | "[lang|=en]", 86 | "a[href^=\"https\"]", 87 | "a[href$=\".pdf\"]", 88 | "a[href*=\"css\"]", 89 | ".header + .content", 90 | "#firstname", 91 | "table[class=\"body\"] .footer__content td", 92 | "table[class=\"body\"] td.footer__link_wrapper--first"} 93 | 94 | for _, selector := range selectors { 95 | css := Parse(fmt.Sprintf(` %s { 96 | border-collapse: separate; 97 | padding: 10px 0 0 98 | }`, selector)) 99 | 100 | assert.Equal(t, "separate", css.CssRuleList[0].Style.Styles[0].Value.Text()) 101 | assert.Equal(t, "10px 0 0", css.CssRuleList[0].Style.Styles[1].Value.Text()) 102 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), selector) 103 | } 104 | } 105 | 106 | func TestFilterSelectors(t *testing.T) { 107 | selectors := []string{ 108 | "a:active", 109 | "p::after", 110 | "p::before", 111 | "input:checked", 112 | "input:disabled", 113 | "input:in-range", 114 | "input:invalid", 115 | "input:optional", 116 | "input:read-only", 117 | "input:enabled", 118 | "p:empty", 119 | "p:first-child", 120 | "p::first-letter", 121 | "p::first-line", 122 | "p:first-of-type", 123 | "input:focus", 124 | "a:hover", 125 | "p:lang(it)", 126 | "p:last-child", 127 | "p:last-of-type", 128 | "a:link", 129 | ":not(p)", 130 | "p:nth-child(2)", 131 | "p:nth-last-child(2)", 132 | "p:only-of-type", 133 | "p:only-child", 134 | "p:nth-last-of-type(2)", 135 | "div:not(:nth-child(1))", 136 | "div:not(:not(:first-child))", 137 | ":root", 138 | "::selection", 139 | "#news:target"} 140 | 141 | for _, selector := range selectors { 142 | css := Parse(fmt.Sprintf(` %s { 143 | border-collapse: separate; 144 | padding: 10px 0 0 145 | }`, selector)) 146 | 147 | assert.Equal(t, "separate", css.CssRuleList[0].Style.Styles[0].Value.Text()) 148 | assert.Equal(t, "10px 0 0", css.CssRuleList[0].Style.Styles[1].Value.Text()) 149 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), selector) 150 | } 151 | } 152 | 153 | func TestFontFace(t *testing.T) { 154 | css := Parse(`@font-face { 155 | font-family: "Bitstream Vera Serif Bold"; 156 | src: url("https://mdn.mozillademos.org/files/2468/VeraSeBd.ttf"); 157 | } 158 | 159 | body { font-family: "Bitstream Vera Serif Bold", serif }`) 160 | 161 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "") 162 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Value.Text(), "\"Bitstream Vera Serif Bold\"") 163 | assert.Equal(t, css.CssRuleList[0].Style.Styles[1].Value.Text(), "url(\"https://mdn.mozillademos.org/files/2468/VeraSeBd.ttf\")") 164 | assert.Equal(t, css.CssRuleList[1].Style.Styles[0].Value.Text(), "\"Bitstream Vera Serif Bold\", serif") 165 | assert.Equal(t, css.CssRuleList[0].Type, FONT_FACE_RULE) 166 | } 167 | 168 | func TestPage(t *testing.T) { 169 | css := Parse(`@page :first { 170 | margin: 2in 3in; 171 | }`) 172 | 173 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), ":first") 174 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Value.Text(), "2in 3in") 175 | assert.Equal(t, css.CssRuleList[0].Type, PAGE_RULE) 176 | } 177 | 178 | func TestCounterStyle(t *testing.T) { 179 | css := Parse(`@counter-style winners-list { 180 | system: cyclic; 181 | symbols: "\1F44D"; 182 | suffix: " "; 183 | }`) 184 | 185 | assert.Equal(t, css.CssRuleList[0].Style.Selector.Text(), "winners-list") 186 | assert.Equal(t, css.CssRuleList[0].Style.Styles[0].Value.Text(), "cyclic") 187 | assert.Equal(t, css.CssRuleList[0].Style.Styles[1].Value.Text(), "\"\\1F44D\"") 188 | assert.Equal(t, css.CssRuleList[0].Style.Styles[2].Value.Text(), "\" \"") 189 | assert.Equal(t, css.CssRuleList[0].Type, COUNTER_STYLE_RULE) 190 | } 191 | --------------------------------------------------------------------------------