aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMike Crute <mike@crute.us>2023-11-28 19:03:27 -0800
committerMike Crute <mike@crute.us>2023-11-28 19:03:27 -0800
commit30ae390089feb1be634f921deda3fd5e945f2dac (patch)
tree1ef044bd0eebc012ba64bedc7cd7eb27719fefa1
parent9642f1fe3e87913f211df7748ac9cd983d72f840 (diff)
downloadgolib-30ae390089feb1be634f921deda3fd5e945f2dac.tar.bz2
golib-30ae390089feb1be634f921deda3fd5e945f2dac.tar.xz
golib-30ae390089feb1be634f921deda3fd5e945f2dac.zip
web/css: add css preprocessorweb/css/v0.1.0
-rw-r--r--web/css/css_processor.go275
-rw-r--r--web/css/go.mod7
-rw-r--r--web/css/go.sum4
3 files changed, 286 insertions, 0 deletions
diff --git a/web/css/css_processor.go b/web/css/css_processor.go
new file mode 100644
index 0000000..b2ebf44
--- /dev/null
+++ b/web/css/css_processor.go
@@ -0,0 +1,275 @@
1package web
2
3import (
4 "encoding/base64"
5 "fmt"
6 "io"
7 "io/fs"
8 "net/url"
9 "path/filepath"
10 "strings"
11
12 "github.com/aymerick/douceur/css"
13 "github.com/aymerick/douceur/parser"
14)
15
16// Consider https://github.com/tdewolff/minify
17
18func relativeRead(f fs.FS, path string) ([]byte, error) {
19 return fs.ReadFile(f, filepath.Join("./", filepath.Clean(path)))
20}
21
22func ParseWriteSheet(fsys fs.FS, f string, o io.Writer) error {
23 fc, err := relativeRead(fsys, f)
24 if err != nil {
25 return err
26 }
27
28 stylesheet, err := parser.Parse(string(fc))
29 if err != nil {
30 return err
31 }
32
33 for _, rule := range stylesheet.Rules {
34 if err := StringRule(fsys, o, rule); err != nil {
35 return err
36 }
37 }
38
39 return nil
40}
41
42func StringRule(fsys fs.FS, out io.Writer, rule *css.Rule) error {
43 switch rule.Kind {
44 case css.QualifiedRule:
45 // Eliminate empty rule blocks
46 if len(rule.Declarations) == 0 && len(rule.Rules) == 0 {
47 return nil
48 }
49
50 out.Write([]byte(strings.Join(rule.Selectors, ",")))
51 case css.AtRule:
52 switch rule.Name {
53 case "@import":
54 path := rule.Prelude[len("url('") : len(rule.Prelude)-len("')")]
55 return ParseWriteSheet(fsys, path, out)
56 case "@font-face":
57 for _, decl := range rule.Declarations {
58 if decl.Property == "src" {
59 if err := EmbedFont(fsys, decl); err != nil {
60 return err
61 }
62 }
63 }
64 }
65 out.Write([]byte(rule.Name))
66 out.Write([]byte(rule.Prelude))
67 default:
68 return fmt.Errorf("Unknown rule type")
69 }
70
71 if len(rule.Declarations) == 0 && len(rule.Rules) == 0 {
72 out.Write([]byte(";"))
73 } else {
74 out.Write([]byte("{"))
75
76 if rule.EmbedsRules() {
77 for _, subRule := range rule.Rules {
78 if err := StringRule(fsys, out, subRule); err != nil {
79 return err
80 }
81 }
82 } else {
83 for _, decl := range rule.Declarations {
84 if strings.Contains(decl.Value, "__csspre_embed=true") {
85 if err := EmbedDeclaration(fsys, decl); err != nil {
86 return err
87 }
88 }
89
90 fmt.Fprintf(out, "%s:%s", decl.Property, strings.ReplaceAll(decl.Value, "\n", ""))
91 if decl.Important {
92 out.Write([]byte(" !important"))
93 }
94 out.Write([]byte(";"))
95 }
96 }
97 out.Write([]byte("}"))
98 }
99 return nil
100}
101
102type TokenType int
103
104const (
105 QuotedString TokenType = iota
106 FunctionStart
107 Comma
108 Semicolon
109)
110
111type Token struct {
112 Type TokenType
113 Value string
114}
115
116// Tokenize will tokenize a @font-face src string that contains one or
117// more comma separated values containing url and format functions (the
118// values of these functions must be quoted). It returns a slice of
119// tokens
120func Tokenize(v string) []Token {
121 out := []Token{}
122 buf := make([]byte, 0, 1024)
123 inStr := false
124
125 for i := 0; i < len(v); i++ {
126 switch c := v[i]; c {
127 case ' ', '\t', '\n':
128 if inStr {
129 buf = append(buf, c)
130 }
131 continue
132 case '"', '\'':
133 if len(buf) != 0 {
134 out = append(out, Token{QuotedString, string(buf)})
135 inStr = false
136 buf = buf[:0]
137 } else {
138 inStr = true
139 }
140 case '(':
141 out = append(out, Token{FunctionStart, string(buf)})
142 buf = buf[:0]
143 case ')':
144 continue
145 case ',':
146 out = append(out, Token{Type: Comma})
147 buf = buf[:0]
148 default:
149 buf = append(buf, c)
150 }
151 }
152
153 // Semicolons get stripped out before the token stream gets to
154 // this point but add one anyhow to make life easier on downstream
155 // consumers.
156 out = append(out, Token{Type: Semicolon})
157
158 return out
159}
160
161// ParseFontSrc takes a list of Token tokens and parses them into
162// a map indexed by the font format.
163func ParseFontSrc(tokens []Token) (map[string]string, error) {
164 out := map[string]string{}
165
166 url, format, state := "", "", ""
167
168 for _, t := range tokens {
169 switch t.Type {
170 case FunctionStart:
171 state = t.Value
172 case QuotedString:
173 switch state {
174 case "url":
175 url = t.Value
176 case "format":
177 format = t.Value
178 default:
179 return nil, fmt.Errorf("Invalid state: %s", state)
180 }
181 state = ""
182 case Comma, Semicolon:
183 if url == "" {
184 return nil, fmt.Errorf("Invalid comma, no url specified")
185 }
186 if format == "" {
187 return nil, fmt.Errorf("Invalid comma, no format specified")
188 }
189 if _, ok := out[format]; ok {
190 return nil, fmt.Errorf("Font format %s already specified", format)
191 }
192 out[format] = url
193 url, format, state = "", "", ""
194 }
195 }
196
197 return out, nil
198}
199
200// EmbedFont parses a @font-face src and embeds the woff font
201// variant, preserving links to the other variants. It will always make
202// the woff embed the first item in the list of fonts so that browsers
203// that pick the first match will use that one.
204func EmbedFont(fsys fs.FS, decl *css.Declaration) error {
205 font, err := ParseFontSrc(Tokenize(decl.Value))
206 if err != nil {
207 return err
208 }
209
210 i := 0
211 out := make([]string, len(font))
212
213 if woff, ok := font["woff"]; ok && !strings.HasPrefix(woff, "data:") {
214 if fc, err := relativeRead(fsys, woff); err != nil {
215 return err
216 } else {
217 enc := base64.StdEncoding.EncodeToString(fc)
218 out[i] = fmt.Sprintf("url('data:application/octet-stream;base64,%s') format('woff')", enc)
219 i++
220 delete(font, "woff")
221 }
222 }
223
224 for format, value := range font {
225 out[i] = fmt.Sprintf("url('%s') format('%s')", value, format)
226 i++
227 }
228
229 decl.Value = strings.Join(out, ",")
230 return nil
231}
232
233func EmbedDeclaration(fsys fs.FS, decl *css.Declaration) error {
234 toks := Tokenize(decl.Value)
235
236 if len(toks) < 2 || (toks[0].Type != FunctionStart && toks[0].Value != "url") {
237 return fmt.Errorf("Error embedding url: Invalid argument, not a URL")
238 }
239
240 u, err := url.Parse(toks[1].Value)
241 if err != nil {
242 return err
243 }
244
245 if u.Scheme != "" || u.Host != "" {
246 return fmt.Errorf("Error embedding url: can only embed local files")
247 }
248
249 // This shouldn't ever happen if we made it this far
250 if u.Query().Get("__csspre_embed") != "true" {
251 return fmt.Errorf("Error embedding url: URL is not flagged for embedding")
252 }
253
254 fc, err := relativeRead(fsys, u.Path)
255 if err != nil {
256 return fmt.Errorf("Error embedding url: %w", err)
257 }
258
259 ft := filepath.Ext(u.Path)
260 img, ok := map[string]string{
261 ".svg": "image/svg+xml",
262 ".jpg": "image/jpeg",
263 ".jpeg": "image/jpeg",
264 ".png": "image/png",
265 ".gif": "image/gif",
266 }[ft]
267
268 if !ok {
269 return fmt.Errorf("Error embedding url: unknown file type %s", ft)
270 }
271
272 decl.Value = fmt.Sprintf("url(\"data:%s;base64,%s\")", img, base64.StdEncoding.EncodeToString(fc))
273
274 return nil
275}
diff --git a/web/css/go.mod b/web/css/go.mod
new file mode 100644
index 0000000..3a36d58
--- /dev/null
+++ b/web/css/go.mod
@@ -0,0 +1,7 @@
1module code.crute.us/mcrute/golib/web/css
2
3go 1.21.3
4
5require github.com/aymerick/douceur v0.2.0
6
7require github.com/gorilla/css v1.0.1 // indirect
diff --git a/web/css/go.sum b/web/css/go.sum
new file mode 100644
index 0000000..f4df343
--- /dev/null
+++ b/web/css/go.sum
@@ -0,0 +1,4 @@
1github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
2github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
3github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
4github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=