| /* |
| * Copyright 2013 Google Inc. |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| // Author: slamm@google.com (Stephen Lamm) |
| // |
| // Contains implementation of CriticalCssFilter, replaces link tags with |
| // style blocks of critical rules. The full CSS, links and style blocks, |
| // is inserted at the end. That means some CSS will be duplicated. |
| // |
| // TODO(slamm): Group all the inline blocks together (or make sure this filter |
| // works with css_move_to_head_filter). |
| |
| #include "net/instaweb/rewriter/public/critical_css_filter.h" |
| |
| #include <map> |
| #include <utility> |
| |
| #include "base/logging.h" |
| #include "net/instaweb/http/public/log_record.h" |
| #include "net/instaweb/http/public/logging_proto.h" |
| #include "net/instaweb/rewriter/critical_css.pb.h" |
| #include "net/instaweb/rewriter/flush_early.pb.h" |
| #include "net/instaweb/rewriter/public/critical_css_finder.h" |
| #include "net/instaweb/rewriter/public/critical_selector_filter.h" |
| #include "net/instaweb/rewriter/public/css_tag_scanner.h" |
| #include "net/instaweb/rewriter/public/rewrite_driver.h" |
| #include "net/instaweb/rewriter/public/rewrite_options.h" |
| #include "net/instaweb/rewriter/public/server_context.h" |
| #include "pagespeed/kernel/base/basictypes.h" |
| #include "pagespeed/kernel/base/hasher.h" |
| #include "pagespeed/kernel/base/stl_util.h" |
| #include "pagespeed/kernel/base/string.h" |
| #include "pagespeed/kernel/base/string_util.h" |
| #include "pagespeed/kernel/html/html_element.h" |
| #include "pagespeed/kernel/html/html_keywords.h" |
| #include "pagespeed/kernel/html/html_name.h" |
| #include "pagespeed/kernel/html/html_node.h" |
| #include "pagespeed/kernel/html/html_parse.h" |
| #include "pagespeed/kernel/http/google_url.h" |
| #include "pagespeed/kernel/http/user_agent_matcher.h" |
| #include "pagespeed/opt/logging/enums.pb.h" |
| |
| namespace net_instaweb { |
| |
| // TODO(ksimbili): Fix window.onload = addAllStyles call site as it will |
| // override the existing onload function. |
| const char CriticalCssFilter::kAddStylesScript[] = |
| "var stylesAdded = false;" |
| "var addAllStyles = function() {" |
| " if (stylesAdded) return;" |
| " stylesAdded = true;" |
| " var div = document.createElement(\"div\");" |
| " var styleText = \"\";" |
| " var styleElements = document.getElementsByClassName(\"psa_add_styles\");" |
| " for (var i = 0; i < styleElements.length; ++i) {" |
| " styleText += styleElements[i].textContent ||" |
| " styleElements[i].innerHTML || " |
| " styleElements[i].data || \"\";" |
| " }" |
| " div.innerHTML = styleText;" |
| " document.body.appendChild(div);" |
| "};" |
| "if (window.addEventListener) {" |
| " document.addEventListener(\"DOMContentLoaded\", addAllStyles, false);" |
| " window.addEventListener(\"load\", addAllStyles, false);" |
| "} else if (window.attachEvent) {" |
| " window.attachEvent(\"onload\", addAllStyles);" |
| "} else {" |
| " window.onload = addAllStyles;" |
| "}"; |
| |
| // TODO(slamm): Remove this once we complete logging for this filter. |
| const char CriticalCssFilter::kStatsScriptTemplate[] = |
| "window['pagespeed'] = window['pagespeed'] || {};" |
| "window['pagespeed']['criticalCss'] = {" |
| " 'total_critical_inlined_size': %d," |
| " 'total_original_external_size': %d," |
| " 'total_overhead_size': %d," |
| " 'num_replaced_links': %d," |
| " 'num_unreplaced_links': %d" |
| "};"; |
| |
| // TODO(slamm): Check charset like CssInlineFilter::ShouldInline(). |
| |
| // Wrap CSS elements to move them later in the document. |
| // A simple list of elements is insufficient because link tags and style tags |
| // are inserted different. |
| class CriticalCssFilter::CssElement { |
| public: |
| CssElement(HtmlParse* p, HtmlElement* e) |
| : html_parse_(p), element_(p->CloneElement(e)) {} |
| |
| // HtmlParse deletes the element (regardless of whether it is inserted). |
| virtual ~CssElement() {} |
| |
| virtual void AppendTo(HtmlElement* parent) const { |
| html_parse_->AppendChild(parent, element_); |
| } |
| |
| protected: |
| HtmlParse* html_parse_; |
| HtmlElement* element_; |
| |
| private: |
| DISALLOW_COPY_AND_ASSIGN(CssElement); |
| }; |
| |
| // Wrap CSS style blocks to move them later in the document. |
| class CriticalCssFilter::CssStyleElement |
| : public CriticalCssFilter::CssElement { |
| public: |
| CssStyleElement(HtmlParse* p, HtmlElement* e) : CssElement(p, e) {} |
| virtual ~CssStyleElement() {} |
| |
| // Call before InsertBeforeCurrent. |
| void AppendCharactersNode(HtmlCharactersNode* characters_node) { |
| characters_nodes_.push_back( |
| html_parse_->NewCharactersNode(NULL, characters_node->contents())); |
| } |
| |
| virtual void AppendTo(HtmlElement* parent) const { |
| HtmlElement* element = element_; |
| CssElement::AppendTo(parent); |
| for (CharactersNodeVector::const_iterator it = characters_nodes_.begin(), |
| end = characters_nodes_.end(); it != end; ++it) { |
| html_parse_->AppendChild(element, *it); |
| } |
| } |
| |
| protected: |
| typedef std::vector<HtmlCharactersNode*> CharactersNodeVector; |
| CharactersNodeVector characters_nodes_; |
| |
| private: |
| DISALLOW_COPY_AND_ASSIGN(CssStyleElement); |
| }; |
| |
| // Wrap CSS related elements so they can be moved later in the document. |
| CriticalCssFilter::CriticalCssFilter(RewriteDriver* driver, |
| CriticalCssFinder* finder) |
| : CommonFilter(driver), |
| finder_(finder), |
| critical_css_result_(NULL), |
| current_style_element_(NULL) { |
| CHECK(finder_); // a valid finder is expected |
| } |
| |
| CriticalCssFilter::~CriticalCssFilter() { |
| } |
| |
| void CriticalCssFilter::DetermineEnabled(GoogleString* disabled_reason) { |
| bool is_ie = driver()->user_agent_matcher()->IsIe(driver()->user_agent()); |
| if (is_ie) { |
| // Disable critical CSS for IE because conditional-comments are not handled |
| // by the filter. |
| // TODO(slamm): Add conditional-comment support, or enable on IE10 |
| // or higher. By default, IE10 does not support conditional comments. |
| // However, pages can opt into the IE9 behavior with a meta tag: |
| // <meta http-equiv="X-UA-Compatible" content="IE=EmulateIE9"> |
| // IE10 could be enabled if the meta tag is not present. |
| // Short of full conditional-comment support, the filter could also detect |
| // whether conditional-comments are present (while computing critical CSS) |
| // and only disable the filter for IE if they are. |
| driver()->log_record()->LogRewriterHtmlStatus( |
| RewriteOptions::FilterId(RewriteOptions::kPrioritizeCriticalCss), |
| RewriterHtmlApplication::USER_AGENT_NOT_SUPPORTED); |
| |
| *disabled_reason = StrCat("User agent '", driver()->user_agent(), |
| "' appears to be Internet Explorer"); |
| } |
| set_is_enabled(!is_ie); |
| } |
| |
| void CriticalCssFilter::StartDocumentImpl() { |
| // If there is no critical CSS data, the filter is a no-op. |
| // However, the property cache is unavailable in DetermineEnabled |
| // where disabling is possible. |
| CHECK(finder_); |
| critical_css_result_ = finder_->GetCriticalCss(driver()); |
| |
| const bool is_property_cache_miss = critical_css_result_ == NULL; |
| |
| driver()->log_record()->LogRewriterHtmlStatus( |
| RewriteOptions::FilterId(RewriteOptions::kPrioritizeCriticalCss), |
| (is_property_cache_miss ? |
| RewriterHtmlApplication::PROPERTY_CACHE_MISS : |
| RewriterHtmlApplication::ACTIVE)); |
| |
| url_indexes_.clear(); |
| if (!is_property_cache_miss) { |
| for (int i = 0, n = critical_css_result_->link_rules_size(); i < n; ++i) { |
| const GoogleString& url = critical_css_result_->link_rules(i).link_url(); |
| url_indexes_.insert(make_pair(url, i)); |
| } |
| } |
| |
| has_critical_css_ = !url_indexes_.empty(); |
| is_move_link_script_added_ = false; |
| |
| DCHECK(css_elements_.empty()); // emptied in EndDocument() |
| DCHECK(current_style_element_ == NULL); // cleared in EndElement() |
| |
| // Reset the stats since a filter instance may be reused. |
| total_critical_size_ = 0; |
| total_original_size_ = 0; |
| repeated_style_blocks_size_ = 0; |
| num_repeated_style_blocks_ = 0; |
| num_links_ = 0; |
| num_replaced_links_ = 0; |
| } |
| |
| void CriticalCssFilter::EndDocument() { |
| // Don't add link/style tags here, if we are in flushing early driver. We'll |
| // get chance to collect and add them again through flushed early driver. |
| if (num_replaced_links_ > 0 && !driver()->flushing_early()) { |
| HtmlElement* noscript_element = |
| driver()->NewElement(NULL, HtmlName::kNoscript); |
| driver()->AddAttribute(noscript_element, HtmlName::kClass, |
| CriticalSelectorFilter::kNoscriptStylesClass); |
| InsertNodeAtBodyEnd(noscript_element); |
| // Write the full set of CSS elements (critical and non-critical rules). |
| for (CssElementVector::iterator it = css_elements_.begin(), |
| end = css_elements_.end(); it != end; ++it) { |
| (*it)->AppendTo(noscript_element); |
| } |
| |
| HtmlElement* script = driver()->NewElement(NULL, HtmlName::kScript); |
| driver()->AddAttribute(script, HtmlName::kDataPagespeedNoDefer, NULL); |
| InsertNodeAtBodyEnd(script); |
| |
| int num_unreplaced_links_ = num_links_ - num_replaced_links_; |
| int total_overhead_size = |
| total_critical_size_ + repeated_style_blocks_size_; |
| GoogleString critical_css_script = StrCat( |
| kAddStylesScript, |
| StringPrintf(kStatsScriptTemplate, |
| total_critical_size_, |
| total_original_size_, |
| total_overhead_size, |
| num_replaced_links_, |
| num_unreplaced_links_)); |
| AddJsToElement(critical_css_script, script); |
| |
| driver()->log_record()->SetCriticalCssInfo( |
| total_critical_size_, total_original_size_, total_overhead_size); |
| } |
| if (has_critical_css_ && driver()->DebugMode()) { |
| driver()->InsertComment(StringPrintf( |
| "Additional Critical CSS stats:\n" |
| " num_repeated_style_blocks=%d\n" |
| " repeated_style_blocks_size=%d\n" |
| "\n" |
| "From computing the critical CSS:\n" |
| " unhandled_import_count=%d\n" |
| " unhandled_link_count=%d\n" |
| " exception_count=%d\n", |
| num_repeated_style_blocks_, |
| repeated_style_blocks_size_, |
| critical_css_result_->import_count(), |
| critical_css_result_->link_count(), |
| critical_css_result_->exception_count())); |
| } |
| if (!css_elements_.empty()) { |
| STLDeleteElements(&css_elements_); |
| } |
| } |
| |
| void CriticalCssFilter::StartElementImpl(HtmlElement* element) { |
| if (has_critical_css_ && element->keyword() == HtmlName::kStyle) { |
| // Capture the style block because full CSS will be copied to end |
| // of document if critical CSS rules are used. |
| current_style_element_ = new CssStyleElement(driver(), element); |
| num_repeated_style_blocks_ += 1; |
| } |
| } |
| |
| void CriticalCssFilter::Characters(HtmlCharactersNode* characters_node) { |
| CommonFilter::Characters(characters_node); |
| if (current_style_element_ != NULL) { |
| current_style_element_->AppendCharactersNode(characters_node); |
| repeated_style_blocks_size_ += characters_node->contents().size(); |
| } |
| } |
| |
| void CriticalCssFilter::EndElementImpl(HtmlElement* element) { |
| if (current_style_element_ != NULL) { |
| // Capture the current style element. |
| CHECK(element->keyword() == HtmlName::kStyle); |
| css_elements_.push_back(current_style_element_); |
| current_style_element_ = NULL; |
| return; |
| } |
| |
| if (noscript_element() != NULL) { |
| // We are inside a no script element. No point moving further. |
| return; |
| } |
| |
| if (!has_critical_css_) { |
| // No critical CSS, so don't bother going further. Also don't bother |
| // logging a rewrite failure since we've logged it already in StartDocument. |
| return; |
| } |
| |
| HtmlElement::Attribute* href; |
| const char* media; |
| if (!CssTagScanner::ParseCssElement(element, &href, &media)) { |
| // Not a css link element. |
| return; |
| } |
| |
| num_links_++; |
| css_elements_.push_back(new CssElement(driver(), element)); |
| |
| const GoogleString url = DecodeUrl(href->DecodedValueOrNull()); |
| if (url.empty()) { |
| // Unable to decode the link into a valid url. |
| LogRewrite(RewriterApplication::INPUT_URL_INVALID); |
| return; |
| } |
| |
| const CriticalCssResult_LinkRules* link_rules = GetLinkRules(url); |
| if (link_rules == NULL) { |
| // The property wasn't found so we have no rules to apply. |
| LogRewrite(RewriterApplication::PROPERTY_NOT_FOUND); |
| return; |
| } |
| |
| const GoogleString& style_id = |
| driver()->server_context()->hasher()->Hash(url); |
| |
| GoogleString escaped_url; |
| HtmlKeywords::Escape(url, &escaped_url); |
| // If the resource has already been flushed early, just apply it here. This |
| // can be checked by looking up the url in the DOM cohort. If the url is |
| // present in the DOM cohort, it is guaranteed to have been flushed early. |
| if (driver()->flushed_early() && |
| driver()->options()->enable_flush_early_critical_css() && |
| driver()->flush_early_info() != NULL && |
| driver()->flush_early_info()->resource_html().find(escaped_url) != |
| GoogleString::npos) { |
| // In this case we have already added the CSS rules to the head as |
| // part of flushing early. Now, find the rule, remove the disabled tag |
| // and move it here. |
| |
| // Add the JS function definition that moves and applies the flushed early |
| // CSS rules, if it has not already been added. |
| if (!is_move_link_script_added_) { |
| is_move_link_script_added_ = true; |
| HtmlElement* script = |
| driver()->NewElement(element->parent(), HtmlName::kScript); |
| // TODO(slamm): Remove this attribute and update webdriver test as needed. |
| driver()->AddAttribute( |
| script, HtmlName::kId, CriticalSelectorFilter::kMoveScriptId); |
| driver()->AddAttribute(script, HtmlName::kDataPagespeedNoDefer, NULL); |
| driver()->InsertNodeBeforeNode(element, script); |
| AddJsToElement( |
| CriticalSelectorFilter::kApplyFlushEarlyCss, script); |
| } |
| |
| HtmlElement* script_element = |
| driver()->NewElement(element->parent(), HtmlName::kScript); |
| driver()->AddAttribute( |
| script_element, HtmlName::kDataPagespeedNoDefer, NULL); |
| if (!driver()->ReplaceNode(element, script_element)) { |
| LogRewrite(RewriterApplication::REPLACE_FAILED); |
| return; |
| } |
| GoogleString js_data = StringPrintf( |
| CriticalSelectorFilter::kInvokeFlushEarlyCssTemplate, |
| style_id.c_str(), media); |
| |
| AddJsToElement(js_data, script_element); |
| } else { |
| // Replace link with critical CSS rules. |
| HtmlElement* style_element = |
| driver()->NewElement(element->parent(), HtmlName::kStyle); |
| if (!driver()->ReplaceNode(element, style_element)) { |
| LogRewrite(RewriterApplication::REPLACE_FAILED); |
| return; |
| } |
| |
| driver()->AppendChild(style_element, driver()->NewCharactersNode( |
| element, link_rules->critical_rules())); |
| // If the link tag has a media attribute, copy it over to the style. |
| if (media != NULL && strcmp(media, "") != 0) { |
| driver()->AddEscapedAttribute( |
| style_element, HtmlName::kMedia, media); |
| } |
| |
| // Add a special attribute to style element so the flush early filter |
| // can identify the element and flush these elements early as link tags. |
| // By flushing the inlined link style tags early, the content can be |
| // downloaded early before the HTML arrives. |
| if (driver()->flushing_early()) { |
| driver()->AddAttribute(style_element, HtmlName::kDataPagespeedFlushStyle, |
| style_id); |
| } |
| } |
| |
| // TODO(mpalem): Stats need to be updated for total critical css size when |
| // the css rules are flushed early. |
| int critical_size = link_rules->critical_rules().length(); |
| int original_size = link_rules->original_size(); |
| total_critical_size_ += critical_size; |
| total_original_size_ += original_size; |
| if (driver()->DebugMode()) { |
| driver()->InsertComment(StringPrintf( |
| "Critical CSS applied:\n" |
| "critical_size=%d\n" |
| "original_size=%d\n" |
| "original_src=%s\n", |
| critical_size, original_size, link_rules->link_url().c_str())); |
| } |
| |
| num_replaced_links_++; |
| LogRewrite(RewriterApplication::APPLIED_OK); |
| } |
| |
| GoogleString CriticalCssFilter::DecodeUrl(const GoogleString& url) { |
| GoogleUrl gurl(driver()->base_url(), url); |
| if (!gurl.IsWebValid()) { |
| return ""; |
| } |
| StringVector decoded_urls; |
| // Decode the url if it is pagespeed encoded. |
| if (driver()->DecodeUrl(gurl, &decoded_urls)) { |
| if (decoded_urls.size() == 1) { |
| return decoded_urls.at(0); |
| } else { |
| driver()->InfoHere("Critical CSS: Unable to process combined URL: %s", |
| url.c_str()); |
| return ""; |
| } |
| } |
| return gurl.Spec().as_string(); |
| } |
| |
| const CriticalCssResult_LinkRules* CriticalCssFilter::GetLinkRules( |
| const GoogleString& decoded_url) { |
| UrlIndexes::const_iterator it = url_indexes_.find(decoded_url); |
| if (it == url_indexes_.end()) { |
| driver()->InfoHere("Critical CSS rules not found for URL: %s", |
| decoded_url.c_str()); |
| return NULL; |
| } |
| |
| // Use "mutable" to get a pointer. A reference does not make sense here. |
| return critical_css_result_->mutable_link_rules(it->second); |
| } |
| |
| void CriticalCssFilter::LogRewrite(int status) { |
| driver()->log_record()->SetRewriterLoggingStatus( |
| RewriteOptions::FilterId(RewriteOptions::kPrioritizeCriticalCss), |
| static_cast<RewriterApplication::Status>(status)); |
| } |
| |
| } // namespace net_instaweb |