1package inliner
2
3import (
4	"fmt"
5	"strconv"
6	"strings"
7
8	"github.com/PuerkitoBio/goquery"
9	"github.com/aymerick/douceur/css"
10	"github.com/aymerick/douceur/parser"
11	"golang.org/x/net/html"
12)
13
14const (
15	eltMarkerAttr = "douceur-mark"
16)
17
18var unsupportedSelectors = []string{
19	":active", ":after", ":before", ":checked", ":disabled", ":enabled",
20	":first-line", ":first-letter", ":focus", ":hover", ":invalid", ":in-range",
21	":lang", ":link", ":root", ":selection", ":target", ":valid", ":visited"}
22
23// Inliner presents a CSS Inliner
24type Inliner struct {
25	// Raw HTML
26	html string
27
28	// Parsed HTML document
29	doc *goquery.Document
30
31	// Parsed stylesheets
32	stylesheets []*css.Stylesheet
33
34	// Collected inlinable style rules
35	rules []*StyleRule
36
37	// HTML elements matching collected inlinable style rules
38	elements map[string]*Element
39
40	// CSS rules that are not inlinable but that must be inserted in output document
41	rawRules []fmt.Stringer
42
43	// current element marker value
44	eltMarker int
45}
46
47// NewInliner instanciates a new Inliner
48func NewInliner(html string) *Inliner {
49	return &Inliner{
50		html:     html,
51		elements: make(map[string]*Element),
52	}
53}
54
55// Inline inlines css into html document
56func Inline(html string) (string, error) {
57	result, err := NewInliner(html).Inline()
58	if err != nil {
59		return "", err
60	}
61
62	return result, nil
63}
64
65// Inline inlines CSS and returns HTML
66func (inliner *Inliner) Inline() (string, error) {
67	// parse HTML document
68	if err := inliner.parseHTML(); err != nil {
69		return "", err
70	}
71
72	// parse stylesheets
73	if err := inliner.parseStylesheets(); err != nil {
74		return "", err
75	}
76
77	// collect elements and style rules
78	inliner.collectElementsAndRules()
79
80	// inline css
81	if err := inliner.inlineStyleRules(); err != nil {
82		return "", err
83	}
84
85	// insert raw stylesheet
86	inliner.insertRawStylesheet()
87
88	// generate HTML document
89	return inliner.genHTML()
90}
91
92// Parses raw html
93func (inliner *Inliner) parseHTML() error {
94	doc, err := goquery.NewDocumentFromReader(strings.NewReader(inliner.html))
95	if err != nil {
96		return err
97	}
98
99	inliner.doc = doc
100
101	return nil
102}
103
104// Parses and removes stylesheets from HTML document
105func (inliner *Inliner) parseStylesheets() error {
106	var result error
107
108	inliner.doc.Find("style").EachWithBreak(func(i int, s *goquery.Selection) bool {
109		stylesheet, err := parser.Parse(s.Text())
110		if err != nil {
111			result = err
112			return false
113		}
114
115		inliner.stylesheets = append(inliner.stylesheets, stylesheet)
116
117		// removes parsed stylesheet
118		s.Remove()
119
120		return true
121	})
122
123	return result
124}
125
126// Collects HTML elements matching parsed stylesheets, and thus collect used style rules
127func (inliner *Inliner) collectElementsAndRules() {
128	for _, stylesheet := range inliner.stylesheets {
129		for _, rule := range stylesheet.Rules {
130			if rule.Kind == css.QualifiedRule {
131				// Let's go!
132				inliner.handleQualifiedRule(rule)
133			} else {
134				// Keep it 'as is'
135				inliner.rawRules = append(inliner.rawRules, rule)
136			}
137		}
138	}
139}
140
141// Handles parsed qualified rule
142func (inliner *Inliner) handleQualifiedRule(rule *css.Rule) {
143	for _, selector := range rule.Selectors {
144		if Inlinable(selector) {
145			inliner.doc.Find(selector).Each(func(i int, s *goquery.Selection) {
146				// get marker
147				eltMarker, exists := s.Attr(eltMarkerAttr)
148				if !exists {
149					// mark element
150					eltMarker = strconv.Itoa(inliner.eltMarker)
151					s.SetAttr(eltMarkerAttr, eltMarker)
152					inliner.eltMarker++
153
154					// add new element
155					inliner.elements[eltMarker] = NewElement(s)
156				}
157
158				// add style rule for element
159				inliner.elements[eltMarker].addStyleRule(NewStyleRule(selector, rule.Declarations))
160			})
161		} else {
162			// Keep it 'as is'
163			inliner.rawRules = append(inliner.rawRules, NewStyleRule(selector, rule.Declarations))
164		}
165	}
166}
167
168// Inline style rules in HTML document
169func (inliner *Inliner) inlineStyleRules() error {
170	for _, element := range inliner.elements {
171		// remove marker
172		element.elt.RemoveAttr(eltMarkerAttr)
173
174		// inline element
175		err := element.inline()
176		if err != nil {
177			return err
178		}
179	}
180
181	return nil
182}
183
184// Computes raw CSS rules
185func (inliner *Inliner) computeRawCSS() string {
186	result := ""
187
188	for _, rawRule := range inliner.rawRules {
189		result += rawRule.String()
190		result += "\n"
191	}
192
193	return result
194}
195
196// Insert raw CSS rules into HTML document
197func (inliner *Inliner) insertRawStylesheet() {
198	rawCSS := inliner.computeRawCSS()
199	if rawCSS != "" {
200		// create <style> element
201		cssNode := &html.Node{
202			Type: html.TextNode,
203			Data: "\n" + rawCSS,
204		}
205
206		styleNode := &html.Node{
207			Type: html.ElementNode,
208			Data: "style",
209			Attr: []html.Attribute{html.Attribute{Key: "type", Val: "text/css"}},
210		}
211
212		styleNode.AppendChild(cssNode)
213
214		// append to <head> element
215		headNode := inliner.doc.Find("head")
216		if headNode == nil {
217			// @todo Create head node !
218			panic("NOT IMPLEMENTED: create missing <head> node")
219		}
220
221		headNode.AppendNodes(styleNode)
222	}
223}
224
225// Generates HTML
226func (inliner *Inliner) genHTML() (string, error) {
227	return inliner.doc.Html()
228}
229
230// Inlinable returns true if given selector is inlinable
231func Inlinable(selector string) bool {
232	if strings.Contains(selector, "::") {
233		return false
234	}
235
236	for _, badSel := range unsupportedSelectors {
237		if strings.Contains(selector, badSel) {
238			return false
239		}
240	}
241
242	return true
243}
244