├── .github └── FUNDING.yml ├── .gitignore ├── .gogo-release ├── LICENSE ├── README.md ├── bundle.go ├── bundle_test.go ├── cmd └── singlepage │ ├── main.go │ └── main_test.go ├── css.go ├── css_test.go ├── go.mod ├── go.sum ├── helpers.go ├── helpers_test.go └── testdata ├── a.css ├── a.html ├── a.js ├── a.min.html ├── a.png ├── aeon.html ├── github.html └── wikipedia.html /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: arp242 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /singlepage 2 | /dist 3 | -------------------------------------------------------------------------------- /.gogo-release: -------------------------------------------------------------------------------- 1 | build_flags="$build_flags -ldflags '-X \"zgo.at/zli.version=$tag$commit_info\" -X \"zgo.at/zli.progname=singlepage\"'" 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © Martin Tournoij 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Inline CSS, JavaScript, and images in a HTML file to distribute a stand-alone 2 | HTML document without external dependencies. 3 | 4 | You can download binaries from the [releases] page, or compile from source with 5 | `go install zgo.at/singlepage/cmd/singlepage@latest`, which will put a binary in 6 | `~/go/bin/`. 7 | 8 | Run it with as `singlepage file.html > bundled.html` or `cat file.html | 9 | singlepage > bundled.html`. There are a bunch of options; use `singlepage -help` 10 | to see the full documentation. 11 | 12 | Use the `zgo.at/singlepage` package if you want to integrate this in a Go 13 | program. Also see the API docs: https://godocs.io/zgo.at/singlepage 14 | 15 | It uses [tdewolff/minify] for minification, so please report bugs or other 16 | questions there. 17 | 18 | [tdewolff/minify]: https://github.com/tdewolff/minify 19 | [releases]: https://github.com/arp242/singlepage/releases 20 | 21 | 22 | Why would I want to use this? 23 | ----------------------------- 24 | There are a few reasons: 25 | 26 | - Sometimes distributing a single HTML document is easier; for example for 27 | rendered HTML documentation. 28 | 29 | - It makes pages slightly faster to load if your CSS/JS assets are small(-ish); 30 | especially on slower connections. 31 | 32 | - As a slightly less practical and more ideological point, I liked the web 33 | before it became this jumbled mess of obnoxious JavaScript and excessive CSS, 34 | and I like the concept of self-contained HTML documents. 35 | -------------------------------------------------------------------------------- /bundle.go: -------------------------------------------------------------------------------- 1 | package singlepage 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "errors" 7 | "fmt" 8 | "mime" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/PuerkitoBio/goquery" 13 | "github.com/tdewolff/minify/v2" 14 | "github.com/tdewolff/minify/v2/css" 15 | "github.com/tdewolff/minify/v2/html" 16 | "github.com/tdewolff/minify/v2/js" 17 | "zgo.at/zstd/zint" 18 | ) 19 | 20 | const ( 21 | _ zint.Bitflag16 = 0 22 | HTML zint.Bitflag16 = 1 << (iota - 1) 23 | CSS 24 | JS 25 | Image 26 | Font 27 | ) 28 | 29 | // Options for Bundle(). 30 | type Options struct { 31 | Root string 32 | Strict bool 33 | Quiet bool 34 | Local zint.Bitflag16 35 | Remote zint.Bitflag16 36 | Minify zint.Bitflag16 37 | } 38 | 39 | // Everything is an Options struct with everything enabled. 40 | var Everything = Options{ 41 | Local: CSS | JS | Image, 42 | Remote: CSS | JS | Image, 43 | Minify: CSS | JS | Image, 44 | } 45 | 46 | var minifier *minify.M 47 | 48 | func init() { 49 | minifier = minify.New() 50 | minifier.AddFunc("css", css.Minify) 51 | minifier.AddFunc("html", html.Minify) 52 | minifier.AddFunc("js", js.Minify) 53 | } 54 | 55 | // NewOptions creates a new Options instance. 56 | func NewOptions(root string, strict, quiet bool) Options { 57 | return Options{Root: root, Strict: strict, Quiet: quiet} 58 | } 59 | 60 | // Commandline modifies the Options from the format accepted in the commandline 61 | // tool's flags. 62 | func (opts *Options) Commandline(local, remote, minify []string) error { 63 | for _, v := range local { 64 | switch strings.TrimSpace(strings.ToLower(v)) { 65 | case "": 66 | continue 67 | case "css": 68 | opts.Local |= CSS 69 | case "js", "javascript": 70 | opts.Local |= JS 71 | case "img", "image", "images": 72 | opts.Local |= Image 73 | case "font", "fonts": 74 | opts.Local |= Font 75 | default: 76 | return fmt.Errorf("unknown value for -local: %q", v) 77 | } 78 | } 79 | for _, v := range remote { 80 | switch strings.TrimSpace(strings.ToLower(v)) { 81 | case "": 82 | continue 83 | case "css": 84 | opts.Remote |= CSS 85 | case "js", "javascript": 86 | opts.Remote |= JS 87 | case "img", "image", "images": 88 | opts.Remote |= Image 89 | case "font", "fonts": 90 | opts.Remote |= Font 91 | default: 92 | return fmt.Errorf("unknown value for -remote: %q", v) 93 | } 94 | } 95 | for _, v := range minify { 96 | switch strings.TrimSpace(strings.ToLower(v)) { 97 | case "": 98 | continue 99 | case "css": 100 | opts.Minify |= CSS 101 | case "js", "javascript": 102 | opts.Minify |= JS 103 | case "html": 104 | opts.Minify |= HTML 105 | default: 106 | return fmt.Errorf("unknown value for -minify: %q", v) 107 | } 108 | } 109 | return nil 110 | } 111 | 112 | // Bundle the resources in a HTML document according to the given options. 113 | func Bundle(html []byte, opts Options) (string, error) { 114 | if opts.Root != "./" { 115 | opts.Root = strings.TrimRight(opts.Root, "/") 116 | } 117 | 118 | doc, err := goquery.NewDocumentFromReader(bytes.NewReader(html)) 119 | if err != nil { 120 | return "", err 121 | } 122 | 123 | if err := minifyStyleTags(doc, opts); err != nil { 124 | return "", fmt.Errorf("minifyStyleTags: %w", err) 125 | } 126 | if err := replaceCSSLinks(doc, opts); err != nil { 127 | return "", fmt.Errorf("replaceCSSLinks: %w", err) 128 | } 129 | if err := replaceCSSImports(doc, opts); err != nil { 130 | return "", fmt.Errorf("replaceCSSImports: %w", err) 131 | } 132 | if err := replaceJS(doc, opts); err != nil { 133 | return "", fmt.Errorf("replaceJS: %w", err) 134 | } 135 | if err := replaceImg(doc, opts); err != nil { 136 | return "", fmt.Errorf("replaceImg: %w", err) 137 | } 138 | 139 | h, err := doc.Html() 140 | if err != nil { 141 | return "", err 142 | } 143 | if opts.Minify.Has(HTML) { 144 | return minifier.String("html", h) 145 | } 146 | return h, nil 147 | } 148 | 149 | func minifyStyleTags(doc *goquery.Document, opts Options) (err error) { 150 | if !opts.Minify.Has(CSS) { 151 | return nil 152 | } 153 | 154 | doc.Find(`style`).EachWithBreak(func(i int, s *goquery.Selection) bool { 155 | var f string 156 | f, err = minifier.String("css", s.Text()) 157 | if err != nil { 158 | return false 159 | } 160 | s.SetText(f) 161 | return true 162 | }) 163 | 164 | return err 165 | } 166 | 167 | func replaceJS(doc *goquery.Document, opts Options) (err error) { 168 | if !opts.Local.Has(JS) && !opts.Remote.Has(JS) { 169 | return nil 170 | } 171 | 172 | var cont bool 173 | doc.Find(`script`).EachWithBreak(func(i int, s *goquery.Selection) bool { 174 | path, ok := s.Attr("src") 175 | if !ok { 176 | if !opts.Minify.Has(JS) { 177 | return true 178 | } 179 | 180 | var f string 181 | f, err = minifier.String("js", s.Text()) 182 | if err != nil { 183 | return false 184 | } 185 | s.SetText(f) 186 | return true 187 | } 188 | path = opts.Root + path 189 | 190 | if isRemote(path) && !opts.Remote.Has(JS) { 191 | return true 192 | } 193 | if !isRemote(path) && !opts.Local.Has(JS) { 194 | return true 195 | } 196 | 197 | var f []byte 198 | f, err = readPath(path) 199 | cont, err = warn(opts, err) 200 | if err != nil { 201 | return false 202 | } 203 | if !cont { 204 | return true 205 | } 206 | 207 | if opts.Minify.Has(JS) { 208 | f, err = minifier.Bytes("js", f) 209 | if err != nil { 210 | return false 211 | } 212 | } 213 | 214 | s.AfterHtml("") 215 | s.Remove() 216 | return true 217 | }) 218 | 219 | return err 220 | } 221 | 222 | func replaceImg(doc *goquery.Document, opts Options) (err error) { 223 | if !opts.Local.Has(Image) && !opts.Remote.Has(Image) { 224 | return nil 225 | } 226 | 227 | var cont bool 228 | doc.Find(`img`).EachWithBreak(func(i int, s *goquery.Selection) bool { 229 | path, ok := s.Attr("src") 230 | if !ok { 231 | return true 232 | } 233 | path = opts.Root + path 234 | 235 | if strings.HasPrefix(path, "data:") { 236 | return true 237 | } 238 | 239 | if isRemote(path) && !opts.Remote.Has(Image) { 240 | return true 241 | } 242 | if !isRemote(path) && !opts.Local.Has(Image) { 243 | return true 244 | } 245 | 246 | var f []byte 247 | f, err = readPath(path) 248 | cont, err = warn(opts, err) 249 | if err != nil { 250 | return false 251 | } 252 | if !cont { 253 | return true 254 | } 255 | 256 | m := mime.TypeByExtension(filepath.Ext(path)) 257 | if m == "" { 258 | cont, err = warn(opts, &ParseError{Path: path, Err: errors.New("could not find MIME type")}) 259 | if err != nil { 260 | return false 261 | } 262 | if !cont { 263 | return true 264 | } 265 | } 266 | 267 | s.SetAttr("src", fmt.Sprintf("data:%v;base64,%v", 268 | m, base64.StdEncoding.EncodeToString(f))) 269 | return true 270 | }) 271 | 272 | return err 273 | } 274 | -------------------------------------------------------------------------------- /bundle_test.go: -------------------------------------------------------------------------------- 1 | package singlepage 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/PuerkitoBio/goquery" 11 | "zgo.at/zstd/ztest" 12 | ) 13 | 14 | func TestNewOptions(t *testing.T) { 15 | tests := []struct { 16 | root string 17 | local, remote, minify []string 18 | want Options 19 | }{ 20 | {"./", []string{"css"}, []string{""}, []string{"CSS", "jS"}, Options{ 21 | Root: "./", 22 | Local: CSS, 23 | Minify: CSS | JS, 24 | }}, 25 | } 26 | 27 | for i, tt := range tests { 28 | t.Run(fmt.Sprintf("%v", i), func(t *testing.T) { 29 | out := NewOptions(tt.root, false, false) 30 | err := out.Commandline(tt.local, tt.remote, tt.minify) 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | 35 | if !reflect.DeepEqual(tt.want, out) { 36 | t.Errorf("\nout: %#v\nwant: %#v\n", out, tt.want) 37 | } 38 | }) 39 | } 40 | } 41 | 42 | func TestReadFile(t *testing.T) { 43 | tests := []struct { 44 | in, want string 45 | }{ 46 | {"./bundle_test.go", "package singlepage"}, 47 | {"//example.com", ""}, 48 | } 49 | 50 | for _, tt := range tests { 51 | t.Run(tt.in, func(t *testing.T) { 52 | out, err := readPath(tt.in) 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | 57 | o := string(bytes.Split(out, []byte("\n"))[0]) 58 | if o != tt.want { 59 | t.Errorf("\nout: %#v\nwant: %#v\n", o, tt.want) 60 | } 61 | }) 62 | } 63 | } 64 | 65 | func TestReplaceJS(t *testing.T) { 66 | tests := []struct { 67 | in, want string 68 | opts Options 69 | wantErr string 70 | }{ 71 | { 72 | ``, 73 | ``, 74 | Options{Local: JS, Minify: JS}, 75 | "", 76 | }, 77 | { 78 | ``, 79 | "", 80 | Options{Local: JS}, 81 | "", 82 | }, 83 | { 84 | ``, 85 | ``, 86 | Options{}, 87 | "", 88 | }, 89 | { 90 | ``, 91 | ``, 92 | Options{Local: JS}, 93 | "", 94 | }, 95 | { 96 | ``, 97 | ``, 98 | Options{Local: JS, Strict: true}, 99 | "no such file or directory", 100 | }, 101 | } 102 | 103 | for _, tt := range tests { 104 | t.Run(tt.in, func(t *testing.T) { 105 | tt.in = `` + tt.in + `` 106 | tt.want = `` + tt.want + `` 107 | 108 | doc, err := goquery.NewDocumentFromReader(strings.NewReader(tt.in)) 109 | if err != nil { 110 | t.Fatal(err) 111 | } 112 | 113 | err = replaceJS(doc, tt.opts) 114 | if !ztest.ErrorContains(err, tt.wantErr) { 115 | t.Fatalf("wrong error\nout: %v\nwant: %v\n", err, tt.wantErr) 116 | } 117 | 118 | h, err := doc.Html() 119 | if err != nil { 120 | t.Fatal(err) 121 | } 122 | 123 | o := string(h) 124 | if o != tt.want { 125 | t.Errorf("\nout: %#v\nwant: %#v\n", o, tt.want) 126 | } 127 | }) 128 | } 129 | } 130 | 131 | func TestReplaceImg(t *testing.T) { 132 | tests := []struct { 133 | in, want string 134 | opts Options 135 | wantErr string 136 | }{ 137 | { 138 | ``, 139 | ``, 140 | Options{Local: Image}, 141 | "", 142 | }, 143 | { 144 | ``, 145 | ``, 146 | Options{}, 147 | "", 148 | }, 149 | } 150 | 151 | for _, tt := range tests { 152 | t.Run(tt.in, func(t *testing.T) { 153 | tt.in = `` + tt.in + `` 154 | tt.want = `` + tt.want + `` 155 | 156 | doc, err := goquery.NewDocumentFromReader(strings.NewReader(tt.in)) 157 | if err != nil { 158 | t.Fatal(err) 159 | } 160 | 161 | err = replaceImg(doc, tt.opts) 162 | if !ztest.ErrorContains(err, tt.wantErr) { 163 | t.Fatalf("wrong error\nout: %v\nwant: %v\n", err, tt.wantErr) 164 | } 165 | 166 | h, err := doc.Html() 167 | if err != nil { 168 | t.Fatal(err) 169 | } 170 | 171 | o := string(h) 172 | if o != tt.want { 173 | t.Errorf("\nout: %#v\nwant: %#v\n", o, tt.want) 174 | } 175 | }) 176 | } 177 | } 178 | 179 | func TestBundle(t *testing.T) { 180 | tests := []struct { 181 | in, want []byte 182 | opts Options 183 | wantErr string 184 | }{ 185 | { 186 | ztest.Read(t, "./testdata/a.html"), 187 | ztest.Read(t, "./testdata/a.min.html"), 188 | Options{Minify: HTML}, 189 | "", 190 | }, 191 | //{ 192 | // ztest.Read(t, "./testdata/a.html"), 193 | // ztest.Read(t, "./testdata/a.html"), 194 | // Options{}, 195 | //}, 196 | } 197 | 198 | for i, tt := range tests { 199 | t.Run(fmt.Sprintf("%v", i), func(t *testing.T) { 200 | o, err := Bundle(tt.in, tt.opts) 201 | if !ztest.ErrorContains(err, tt.wantErr) { 202 | t.Fatalf("wrong error\nout: %v\nwant: %v\n", err, tt.wantErr) 203 | } 204 | 205 | want := strings.TrimSpace(string(tt.want)) 206 | o = strings.TrimSpace(o) 207 | if o != want { 208 | t.Errorf("\nout: %#v\nwant: %#v\n", o, want) 209 | } 210 | }) 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /cmd/singlepage/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | "zgo.at/singlepage" 10 | "zgo.at/zli" 11 | ) 12 | 13 | const usage = `usage: singlepage [flags] file.html 14 | 15 | Bundle external assets in a HTML file to distribute a stand-alone HTML document. 16 | https://github.com/arp242/singlepage 17 | 18 | The -local, -remote, and -minify can be given more than once and/or accept a 19 | comma-separated list of asset types; the default is to include all the supported 20 | types. Pass an empty string to disable the feature (e.g. -remote ''). 21 | 22 | Flags: 23 | 24 | -h, -help Show this help. 25 | 26 | -v, -version Show version; add twice for detailed build info. 27 | 28 | -q, -quiet Don't print warnings to stderr. 29 | 30 | -S, -strict Fail on lookup or parse errors instead of leaving the content alone. 31 | 32 | -w, -write Write the result to the input file instead of printing it. 33 | 34 | -r, -root Assets are looked up relative to the path in -root, which may 35 | be a remote path (e.g. http://example.com), in which case all 36 | "//resources" are fetched relative to that domain (and are 37 | treated as external). 38 | 39 | -l, -local Filetypes to include from the local filesystem. Supports css, 40 | js, img, and font. 41 | 42 | -r, -remote Filetypes to include from remote sources. Only only 43 | "http://", "https://", and "//" are supported; "//" is 44 | treated as "https://". Suports css, js, img, and font. 45 | 46 | -m, -minify Filetypes to minify. Support js, css, and html. 47 | ` 48 | 49 | func fatal(err error) { 50 | if err == nil { 51 | return 52 | } 53 | 54 | zli.Errorf(err) 55 | fmt.Print("\n", usage) 56 | zli.Exit(1) 57 | } 58 | 59 | func main() { 60 | f := zli.NewFlags(os.Args) 61 | var ( 62 | help = f.Bool(false, "h", "help") 63 | versionF = f.IntCounter(0, "v", "version") 64 | quiet = f.Bool(false, "q", "quiet") 65 | strict = f.Bool(false, "S", "strict") 66 | write = f.Bool(false, "w", "write") 67 | root = f.String("", "r", "root", "") 68 | local = f.StringList([]string{"css,js,img"}, "l", "local") 69 | remote = f.StringList([]string{"css,js,img"}, "r", "remote") 70 | minify = f.StringList([]string{"css,js,html"}, "m", "minify") 71 | ) 72 | fatal(f.Parse()) 73 | 74 | if help.Bool() { 75 | fmt.Print(usage) 76 | return 77 | } 78 | 79 | if versionF.Int() > 0 { 80 | zli.PrintVersion(versionF.Int() > 1) 81 | return 82 | } 83 | 84 | opts := singlepage.NewOptions(root.String(), strict.Bool(), quiet.Bool()) 85 | err := opts.Commandline(local.StringsSplit(","), remote.StringsSplit(","), minify.StringsSplit(",")) 86 | fatal(err) 87 | 88 | path := f.Shift() 89 | if path == "" && write.Bool() { 90 | fatal(errors.New("cannot use -write when reading from stdin")) 91 | } 92 | 93 | fp, err := zli.InputOrFile(path, quiet.Bool()) 94 | fatal(err) 95 | defer fp.Close() 96 | 97 | b, err := io.ReadAll(fp) 98 | fatal(err) 99 | 100 | html, err := singlepage.Bundle(b, opts) 101 | fatal(err) 102 | 103 | if write.Bool() { 104 | fp.Close() 105 | fatal(os.WriteFile(path, []byte(html), 0644)) 106 | } else { 107 | fmt.Println(html) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /cmd/singlepage/main_test.go: -------------------------------------------------------------------------------- 1 | // +build testhttp 2 | 3 | package main 4 | 5 | import ( 6 | "flag" 7 | "os" 8 | "testing" 9 | ) 10 | 11 | // Some real-world tests. 12 | func TestMain(t *testing.T) { 13 | for _, tt := range []string{"github", "wikipedia", "aeon"} { 14 | t.Run(tt, func(t *testing.T) { 15 | os.Args = []string{"singlepage", "./testdata/" + tt + ".html"} 16 | flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) 17 | _, err := start() 18 | if err != nil { 19 | t.Errorf("%T: %[1]s", err) 20 | } 21 | }) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /css.go: -------------------------------------------------------------------------------- 1 | package singlepage 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "io" 7 | "mime" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/PuerkitoBio/goquery" 12 | "github.com/tdewolff/parse/v2" 13 | "github.com/tdewolff/parse/v2/css" 14 | ) 15 | 16 | // Replace with 17 | // 18 | func replaceCSSLinks(doc *goquery.Document, opts Options) (err error) { 19 | if !opts.Local.Has(CSS) && !opts.Remote.Has(CSS) { 20 | return nil 21 | } 22 | 23 | var cont bool 24 | doc.Find(`link[rel="stylesheet"]`).EachWithBreak(func(i int, s *goquery.Selection) bool { 25 | path, ok := s.Attr("href") 26 | if !ok { 27 | return true 28 | } 29 | path = opts.Root + path 30 | 31 | if isRemote(path) && !opts.Remote.Has(CSS) { 32 | return true 33 | } 34 | if !isRemote(path) && !opts.Local.Has(CSS) { 35 | return true 36 | } 37 | 38 | var f []byte 39 | f, err = readPath(path) 40 | cont, err = warn(opts, err) 41 | if err != nil { 42 | return false 43 | } 44 | if !cont { 45 | return true 46 | } 47 | 48 | // Replace @imports 49 | var out string 50 | out, err = replaceCSSURLs(opts, string(f)) 51 | if err != nil { 52 | err = fmt.Errorf("could not parse %v: %v", path, err) 53 | return false 54 | } 55 | 56 | if opts.Minify.Has(CSS) { 57 | out, err = minifier.String("css", out) 58 | if err != nil { 59 | err = fmt.Errorf("could not minify %v: %v", path, err) 60 | return false 61 | } 62 | } 63 | 64 | s.AfterHtml("") 65 | s.Remove() 66 | return true 67 | }) 68 | return err 69 | } 70 | 71 | // Replace @import "path"; and url("..") 72 | func replaceCSSImports(doc *goquery.Document, opts Options) (err error) { 73 | if !opts.Local.Has(CSS) && !opts.Remote.Has(CSS) { 74 | return nil 75 | } 76 | 77 | doc.Find("style").EachWithBreak(func(i int, s *goquery.Selection) bool { 78 | var n string 79 | n, err = replaceCSSURLs(opts, s.Text()) 80 | if err != nil { 81 | err = fmt.Errorf("could not parse inline style block %v: %v", i, err) 82 | return false 83 | } 84 | s.SetText(n) 85 | return true 86 | }) 87 | return err 88 | } 89 | 90 | func replaceCSSURLs(opts Options, s string) (string, error) { 91 | l := css.NewLexer(parse.NewInputString(s)) 92 | var out []byte 93 | var cont bool 94 | for { 95 | tt, text := l.Next() 96 | switch { 97 | 98 | case tt == css.ErrorToken: 99 | if l.Err() == io.EOF { 100 | return string(out), nil 101 | } 102 | return string(out), l.Err() 103 | 104 | // @import 105 | case tt == css.AtKeywordToken && string(text) == "@import": 106 | for { 107 | tt2, text2 := l.Next() 108 | if tt2 == css.SemicolonToken { 109 | break 110 | } 111 | if tt2 == css.ErrorToken { 112 | return "", l.Err() 113 | } 114 | 115 | var path string 116 | if tt2 == css.StringToken { 117 | path = strings.Trim(string(text2), `'"`) 118 | } else if tt2 == css.URLToken { 119 | path = string(text2) 120 | path = path[strings.Index(path, "(")+1 : strings.Index(path, ")")] 121 | path = strings.Trim(path, `'"`) 122 | } else { 123 | continue 124 | } 125 | 126 | if path != "" { 127 | b, err := readPath(path) 128 | cont, err = warn(opts, err) 129 | if err != nil { 130 | return "", err 131 | } 132 | if !cont { 133 | continue 134 | } 135 | 136 | nest, err := replaceCSSURLs(opts, string(b)) 137 | if err != nil { 138 | return "", fmt.Errorf("could not load nested CSS file %v: %v", path, err) 139 | } 140 | out = append(out, []byte(nest)...) 141 | } 142 | } 143 | 144 | // Images and fonts 145 | case tt == css.URLToken: 146 | path := string(text) 147 | path = path[strings.Index(path, "(")+1 : strings.Index(path, ")")] 148 | if strings.HasPrefix(path, "data:") { 149 | out = append(out, text...) 150 | continue 151 | } 152 | 153 | path = strings.Trim(path, `'"`) 154 | m := mime.TypeByExtension(filepath.Ext(path)) 155 | if m == "" { 156 | warn(opts, fmt.Errorf("unknown MIME type for %q; skipping", path)) 157 | out = append(out, text...) 158 | continue 159 | } 160 | 161 | remote := isRemote(path) 162 | if strings.HasPrefix(m, "image/") && 163 | ((remote && !opts.Remote.Has(Image)) || (!remote && !opts.Local.Has(Image))) { 164 | out = append(out, text...) 165 | continue 166 | } else if strings.HasPrefix(m, "font/") && 167 | ((remote && !opts.Remote.Has(Font)) || (!remote && !opts.Local.Has(Font))) { 168 | out = append(out, text...) 169 | continue 170 | } 171 | 172 | f, err := readPath(path) 173 | cont, err = warn(opts, err) 174 | if err != nil { 175 | return "", err 176 | } 177 | if !cont { 178 | continue 179 | } 180 | 181 | out = append(out, []byte(fmt.Sprintf("url(data:%v;base64,%v)", 182 | m, base64.StdEncoding.EncodeToString(f)))...) 183 | 184 | default: 185 | out = append(out, text...) 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /css_test.go: -------------------------------------------------------------------------------- 1 | package singlepage 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/PuerkitoBio/goquery" 9 | ) 10 | 11 | func TestReplaceCSSLinks(t *testing.T) { 12 | tests := []struct { 13 | in, want string 14 | opts Options 15 | }{ 16 | { 17 | ``, 18 | ``, 19 | Options{Local: CSS, Minify: CSS}, 20 | }, 21 | { 22 | ``, 23 | "", 24 | Options{Local: CSS}, 25 | }, 26 | { 27 | ``, 28 | ``, 29 | Options{}, 30 | }, 31 | } 32 | 33 | for _, tt := range tests { 34 | t.Run(tt.in, func(t *testing.T) { 35 | tt.in = `` + tt.in + `` 36 | tt.want = `` + tt.want + `` 37 | 38 | doc, err := goquery.NewDocumentFromReader(strings.NewReader(tt.in)) 39 | if err != nil { 40 | t.Fatal(err) 41 | } 42 | 43 | err = replaceCSSLinks(doc, tt.opts) 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | 48 | h, err := doc.Html() 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | 53 | o := string(h) 54 | if o != tt.want { 55 | t.Errorf("\nout: %#v\nwant: %#v\n", o, tt.want) 56 | } 57 | }) 58 | } 59 | } 60 | 61 | func TestReplaceCSSImports(t *testing.T) { 62 | tests := []struct { 63 | in, want string 64 | }{ 65 | { 66 | ``, 70 | "", 71 | }, 72 | } 73 | 74 | for i, tt := range tests { 75 | t.Run(fmt.Sprintf("%v", i), func(t *testing.T) { 76 | tt.in = `` + tt.in + `` 77 | tt.want = `` + tt.want + `` 78 | 79 | doc, err := goquery.NewDocumentFromReader(strings.NewReader(tt.in)) 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | 84 | err = replaceCSSImports(doc, Options{Local: CSS}) 85 | if err != nil { 86 | t.Fatal(err) 87 | } 88 | h, err := doc.Html() 89 | if err != nil { 90 | t.Fatal(err) 91 | } 92 | 93 | o := string(h) 94 | if o != tt.want { 95 | t.Errorf("\nout: %#v\nwant: %#v\n", o, tt.want) 96 | } 97 | }) 98 | } 99 | } 100 | 101 | func TestReplaceCSSURLs(t *testing.T) { 102 | tests := []struct { 103 | in, want string 104 | }{ 105 | {`span { display: block; }`, `span { display: block; }`}, 106 | {`@import './testdata/a.css';`, "div {\n\tdisplay: none;\n}\n"}, 107 | {`@import url("./testdata/a.css");`, "div {\n\tdisplay: none;\n}\n"}, 108 | {`@import url("./testdata/a.css") print;`, "div {\n\tdisplay: none;\n}\n"}, 109 | { 110 | `span { background-image: url('testdata/a.png'); }`, 111 | `span { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QsYBTofXds9gQAAAAZiS0dEAP8A/wD/oL2nkwAAAAxJREFUCB1jkPvPAAACXAEebXgQcwAAAABJRU5ErkJggg==); }`, 112 | }, 113 | { 114 | `span { background-image: url(data:image/png;base64,iVBORw0KGgoAAA==); }`, 115 | `span { background-image: url(data:image/png;base64,iVBORw0KGgoAAA==); }`, 116 | }, 117 | } 118 | 119 | for i, tt := range tests { 120 | t.Run(fmt.Sprintf("%v", i), func(t *testing.T) { 121 | out, err := replaceCSSURLs(Options{Local: CSS | Image}, tt.in) 122 | if err != nil { 123 | t.Fatal(err) 124 | } 125 | if out != tt.want { 126 | t.Errorf("\nout: %#v\nwant: %#v\n", out, tt.want) 127 | } 128 | }) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module zgo.at/singlepage 2 | 3 | go 1.13 4 | 5 | require ( 6 | // TODO: goquery 1.8.0 escapes things inside