├── .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