diff --git a/couchpotato/core/_base/clientscript/main.py b/couchpotato/core/_base/clientscript/main.py index bb380be..e8624b0 100644 --- a/couchpotato/core/_base/clientscript/main.py +++ b/couchpotato/core/_base/clientscript/main.py @@ -1,15 +1,58 @@ from couchpotato.core.event import addEvent +from couchpotato.core.helpers.variable import tryInt from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin +from couchpotato.environment import Env +from minify.cssmin import cssmin +from minify.jsmin import jsmin +import os log = CPLog(__name__) class ClientScript(Plugin): - urls = { - 'style': {}, - 'script': {}, + core_static = { + 'style': [ + 'style/main.css', + 'style/uniform.generic.css', + 'style/uniform.css', + 'style/settings.css', + ], + 'script': [ + 'scripts/library/mootools.js', + 'scripts/library/mootools_more.js', + 'scripts/library/prefix_free.js', + 'scripts/library/uniform.js', + 'scripts/library/form_replacement/form_check.js', + 'scripts/library/form_replacement/form_radio.js', + 'scripts/library/form_replacement/form_dropdown.js', + 'scripts/library/form_replacement/form_selectoption.js', + 'scripts/library/question.js', + 'scripts/library/scrollspy.js', + 'scripts/library/spin.js', + 'scripts/couchpotato.js', + 'scripts/api.js', + 'scripts/library/history.js', + 'scripts/page.js', + 'scripts/block.js', + 'scripts/block/navigation.js', + 'scripts/block/footer.js', + 'scripts/block/menu.js', + 'scripts/page/wanted.js', + 'scripts/page/settings.js', + 'scripts/page/about.js', + 'scripts/page/manage.js', + ], + } + + + urls = {'style': {}, 'script': {}, } + minified = {'style': {}, 'script': {}, } + paths = {'style': {}, 'script': {}, } + comment = { + 'style': '/*** %s:%d ***/\n', + 'script': '// %s:%d\n' } html = { @@ -24,6 +67,66 @@ class ClientScript(Plugin): addEvent('clientscript.get_styles', self.getStyles) addEvent('clientscript.get_scripts', self.getScripts) + addEvent('app.load', self.minify) + + self.addCore() + + def addCore(self): + + for static_type in self.core_static: + for rel_path in self.core_static.get(static_type): + file_path = os.path.join(Env.get('app_dir'), 'couchpotato', 'static', rel_path) + core_url = 'api/%s/static/%s?%s' % (Env.setting('api_key'), rel_path, tryInt(os.path.getmtime(file_path))) + + if static_type == 'script': + self.registerScript(core_url, file_path, position = 'front') + else: + self.registerStyle(core_url, file_path, position = 'front') + + + def minify(self): + + for file_type in ['style', 'script']: + ext = 'js' if file_type is 'script' else 'css' + positions = self.paths.get(file_type, {}) + for position in positions: + files = positions.get(position) + self._minify(file_type, files, position, position + '.' + ext) + + def _minify(self, file_type, files, position, out): + + cache = Env.get('cache_dir') + out_name = 'minified_' + out + out = os.path.join(cache, out_name) + + raw = [] + for file_path in files: + f = open(file_path, 'r').read() + + if file_type == 'script': + data = jsmin(f) + else: + data = cssmin(f) + data = data.replace('../images/', '../static/images/') + + raw.append({'file': file_path, 'date': int(os.path.getmtime(file_path)), 'data': data}) + + # Combine all files together with some comments + data = '' + for r in raw: + data += self.comment.get(file_type) % (r.get('file'), r.get('date')) + data += r.get('data') + '\n\n' + + self.createFile(out, data.strip()) + + if not self.minified.get(file_type): + self.minified[file_type] = {} + if not self.minified[file_type].get(position): + self.minified[file_type][position] = [] + + minified_url = 'api/%s/file.cache/%s?%s' % (Env.setting('api_key'), out_name, tryInt(os.path.getmtime(out))) + self.minified[file_type][position].append(minified_url) + def getStyles(self, *args, **kwargs): return self.get('style', *args, **kwargs) @@ -35,22 +138,27 @@ class ClientScript(Plugin): data = '' if as_html else [] try: + if Env.get('dev'): + return self.minified[type][location] + return self.urls[type][location] except Exception, e: log.error(e) return data - def registerStyle(self, path, position = 'head'): - self.register(path, 'style', position) + def registerStyle(self, api_path, file_path, position = 'head'): + self.register(api_path, file_path, 'style', position) - def registerScript(self, path, position = 'head'): - self.register(path, 'script', position) + def registerScript(self, api_path, file_path, position = 'head'): + self.register(api_path, file_path, 'script', position) - def register(self, filepath, type, location): + def register(self, api_path, file_path, type, location): if not self.urls[type].get(location): self.urls[type][location] = [] + self.urls[type][location].append(api_path) - filePath = filepath - self.urls[type][location].append(filePath) + if not self.paths[type].get(location): + self.paths[type][location] = [] + self.paths[type][location].append(file_path) diff --git a/couchpotato/core/plugins/base.py b/couchpotato/core/plugins/base.py index 73a2c30..edecaa9 100644 --- a/couchpotato/core/plugins/base.py +++ b/couchpotato/core/plugins/base.py @@ -64,7 +64,7 @@ class Plugin(object): for f in glob.glob(os.path.join(self.plugin_path, 'static', '*')): ext = getExt(f) if ext in ['js', 'css']: - fireEvent('register_%s' % ('script' if ext in 'js' else 'style'), path + os.path.basename(f)) + fireEvent('register_%s' % ('script' if ext in 'js' else 'style'), path + os.path.basename(f), f) def showStatic(self, filename): d = os.path.join(self.plugin_path, 'static') diff --git a/couchpotato/core/plugins/file/main.py b/couchpotato/core/plugins/file/main.py index a9eab33..0dc0178 100644 --- a/couchpotato/core/plugins/file/main.py +++ b/couchpotato/core/plugins/file/main.py @@ -71,7 +71,7 @@ class FileManager(Plugin): db = get_session() for root, dirs, walk_files in os.walk(Env.get('cache_dir')): for filename in walk_files: - if root == python_cache: continue + if root == python_cache or 'minified' in filename: continue file_path = os.path.join(root, filename) f = db.query(File).filter(File.path == toUnicode(file_path)).first() if not f: diff --git a/couchpotato/static/scripts/library/prefix_free.js b/couchpotato/static/scripts/library/prefix_free.js index 8dd99e2..b6d9812 100644 --- a/couchpotato/static/scripts/library/prefix_free.js +++ b/couchpotato/static/scripts/library/prefix_free.js @@ -24,6 +24,9 @@ var self = window.StyleFix = { var url = link.href || link.getAttribute('data-href'), base = url.replace(/[^\/]+$/, ''), + base_scheme = (/^[a-z]{3,10}:/.exec(base) || [''])[0], + base_domain = (/^[a-z]{3,10}:\/\/[^\/]+/.exec(base) || [''])[0], + base_query = /^([^?]*)\??/.exec(url)[1], parent = link.parentNode, xhr = new XMLHttpRequest(), process; @@ -43,12 +46,23 @@ var self = window.StyleFix = { // Convert relative URLs to absolute, if needed if(base) { css = css.replace(/url\(\s*?((?:"|')?)(.+?)\1\s*?\)/gi, function($0, quote, url) { - if(!/^([a-z]{3,10}:|\/|#)/i.test(url)) { // If url not absolute & not a hash + if(/^([a-z]{3,10}:|#)/i.test(url)) { // Absolute & or hash-relative + return $0; + } + else if(/^\/\//.test(url)) { // Scheme-relative // May contain sequences like /../ and /./ but those DO work + return 'url("' + base_scheme + url + '")'; + } + else if(/^\//.test(url)) { // Domain-relative + return 'url("' + base_domain + url + '")'; + } + else if(/^\?/.test(url)) { // Query-relative + return 'url("' + base_query + url + '")'; + } + else { + // Path-relative return 'url("' + base + url + '")'; } - - return $0; }); // behavior URLs shoudn’t be converted (Issue #19) @@ -470,4 +484,4 @@ root.className += ' ' + self.prefix; StyleFix.register(self.prefixCSS); -})(document.documentElement); +})(document.documentElement); \ No newline at end of file diff --git a/couchpotato/static/style/page/settings.css b/couchpotato/static/style/settings.css similarity index 100% rename from couchpotato/static/style/page/settings.css rename to couchpotato/static/style/settings.css diff --git a/couchpotato/templates/_desktop.html b/couchpotato/templates/_desktop.html index 8689b66..1d61806 100644 --- a/couchpotato/templates/_desktop.html +++ b/couchpotato/templates/_desktop.html @@ -1,43 +1,14 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% for url in fireEvent('clientscript.get_scripts', as_html = True, single = True) %} + {% for url in fireEvent('clientscript.get_styles', as_html = True, location = 'front', single = True) %} + {% endfor %} + {% for url in fireEvent('clientscript.get_scripts', as_html = True, location = 'front', single = True) %} + {% endfor %} + + {% for url in fireEvent('clientscript.get_scripts', as_html = True, location = 'head', single = True) %} {% endfor %} - {% for url in fireEvent('clientscript.get_styles', as_html = True, single = True) %} + {% for url in fireEvent('clientscript.get_styles', as_html = True, location = 'head', single = True) %} {% endfor %} diff --git a/libs/minify/__init__.py b/libs/minify/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/libs/minify/cssmin.py b/libs/minify/cssmin.py new file mode 100644 index 0000000..c29cb83 --- /dev/null +++ b/libs/minify/cssmin.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# `cssmin.py` - A Python port of the YUI CSS compressor. + + +from StringIO import StringIO # The pure-Python StringIO supports unicode. +import re + + +__version__ = '0.1.1' + + +def remove_comments(css): + """Remove all CSS comment blocks.""" + + iemac = False + preserve = False + comment_start = css.find("/*") + while comment_start >= 0: + # Preserve comments that look like `/*!...*/`. + # Slicing is used to make sure we don"t get an IndexError. + preserve = css[comment_start + 2:comment_start + 3] == "!" + + comment_end = css.find("*/", comment_start + 2) + if comment_end < 0: + if not preserve: + css = css[:comment_start] + break + elif comment_end >= (comment_start + 2): + if css[comment_end - 1] == "\\": + # This is an IE Mac-specific comment; leave this one and the + # following one alone. + comment_start = comment_end + 2 + iemac = True + elif iemac: + comment_start = comment_end + 2 + iemac = False + elif not preserve: + css = css[:comment_start] + css[comment_end + 2:] + else: + comment_start = comment_end + 2 + comment_start = css.find("/*", comment_start) + + return css + + +def remove_unnecessary_whitespace(css): + """Remove unnecessary whitespace characters.""" + + def pseudoclasscolon(css): + + """ + Prevents 'p :link' from becoming 'p:link'. + + Translates 'p :link' into 'p ___PSEUDOCLASSCOLON___link'; this is + translated back again later. + """ + + regex = re.compile(r"(^|\})(([^\{\:])+\:)+([^\{]*\{)") + match = regex.search(css) + while match: + css = ''.join([ + css[:match.start()], + match.group().replace(":", "___PSEUDOCLASSCOLON___"), + css[match.end():]]) + match = regex.search(css) + return css + + css = pseudoclasscolon(css) + # Remove spaces from before things. + css = re.sub(r"\s+([!{};:>+\(\)\],])", r"\1", css) + + # If there is a `@charset`, then only allow one, and move to the beginning. + css = re.sub(r"^(.*)(@charset \"[^\"]*\";)", r"\2\1", css) + css = re.sub(r"^(\s*@charset [^;]+;\s*)+", r"\1", css) + + # Put the space back in for a few cases, such as `@media screen` and + # `(-webkit-min-device-pixel-ratio:0)`. + css = re.sub(r"\band\(", "and (", css) + + # Put the colons back. + css = css.replace('___PSEUDOCLASSCOLON___', ':') + + # Remove spaces from after things. + css = re.sub(r"([!{}:;>+\(\[,])\s+", r"\1", css) + + return css + + +def remove_unnecessary_semicolons(css): + """Remove unnecessary semicolons.""" + + return re.sub(r";+\}", "}", css) + + +def remove_empty_rules(css): + """Remove empty rules.""" + + return re.sub(r"[^\}\{]+\{\}", "", css) + + +def normalize_rgb_colors_to_hex(css): + """Convert `rgb(51,102,153)` to `#336699`.""" + + regex = re.compile(r"rgb\s*\(\s*([0-9,\s]+)\s*\)") + match = regex.search(css) + while match: + colors = match.group(1).split(",") + hexcolor = '#%.2x%.2x%.2x' % tuple(map(int, colors)) + css = css.replace(match.group(), hexcolor) + match = regex.search(css) + return css + + +def condense_zero_units(css): + """Replace `0(px, em, %, etc)` with `0`.""" + + return re.sub(r"([\s:])(0)(px|em|%|in|cm|mm|pc|pt|ex)", r"\1\2", css) + + +def condense_multidimensional_zeros(css): + """Replace `:0 0 0 0;`, `:0 0 0;` etc. with `:0;`.""" + + css = css.replace(":0 0 0 0;", ":0;") + css = css.replace(":0 0 0;", ":0;") + css = css.replace(":0 0;", ":0;") + + # Revert `background-position:0;` to the valid `background-position:0 0;`. + css = css.replace("background-position:0;", "background-position:0 0;") + + return css + + +def condense_floating_points(css): + """Replace `0.6` with `.6` where possible.""" + + return re.sub(r"(:|\s)0+\.(\d+)", r"\1.\2", css) + + +def condense_hex_colors(css): + """Shorten colors from #AABBCC to #ABC where possible.""" + + regex = re.compile(r"([^\"'=\s])(\s*)#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])") + match = regex.search(css) + while match: + first = match.group(3) + match.group(5) + match.group(7) + second = match.group(4) + match.group(6) + match.group(8) + if first.lower() == second.lower(): + css = css.replace(match.group(), match.group(1) + match.group(2) + '#' + first) + match = regex.search(css, match.end() - 3) + else: + match = regex.search(css, match.end()) + return css + + +def condense_whitespace(css): + """Condense multiple adjacent whitespace characters into one.""" + + return re.sub(r"\s+", " ", css) + + +def condense_semicolons(css): + """Condense multiple adjacent semicolon characters into one.""" + + return re.sub(r";;+", ";", css) + + +def wrap_css_lines(css, line_length): + """Wrap the lines of the given CSS to an approximate length.""" + + lines = [] + line_start = 0 + for i, char in enumerate(css): + # It's safe to break after `}` characters. + if char == '}' and (i - line_start >= line_length): + lines.append(css[line_start:i + 1]) + line_start = i + 1 + + if line_start < len(css): + lines.append(css[line_start:]) + return '\n'.join(lines) + + +def cssmin(css, wrap = None): + css = remove_comments(css) + css = condense_whitespace(css) + # A pseudo class for the Box Model Hack + # (see http://tantek.com/CSS/Examples/boxmodelhack.html) + css = css.replace('"\\"}\\""', "___PSEUDOCLASSBMH___") + #css = remove_unnecessary_whitespace(css) + css = remove_unnecessary_semicolons(css) + css = condense_zero_units(css) + css = condense_multidimensional_zeros(css) + css = condense_floating_points(css) + css = normalize_rgb_colors_to_hex(css) + css = condense_hex_colors(css) + if wrap is not None: + css = wrap_css_lines(css, wrap) + css = css.replace("___PSEUDOCLASSBMH___", '"\\"}\\""') + css = condense_semicolons(css) + return css.strip() + + +def main(): + import optparse + import sys + + p = optparse.OptionParser( + prog = "cssmin", version = __version__, + usage = "%prog [--wrap N]", + description = """Reads raw CSS from stdin, and writes compressed CSS to stdout.""") + + p.add_option( + '-w', '--wrap', type = 'int', default = None, metavar = 'N', + help = "Wrap output to approximately N chars per line.") + + options, args = p.parse_args() + sys.stdout.write(cssmin(sys.stdin.read(), wrap = options.wrap)) + + +if __name__ == '__main__': + main() diff --git a/libs/minify/jsmin.py b/libs/minify/jsmin.py new file mode 100644 index 0000000..a1b81f9 --- /dev/null +++ b/libs/minify/jsmin.py @@ -0,0 +1,218 @@ +#!/usr/bin/python + +# This code is original from jsmin by Douglas Crockford, it was translated to +# Python by Baruch Even. The original code had the following copyright and +# license. +# +# /* jsmin.c +# 2007-05-22 +# +# Copyright (c) 2002 Douglas Crockford (www.crockford.com) +# +# Permission is hereby granted, free of charge, to any person obtaining a copy of +# this software and associated documentation files (the "Software"), to deal in +# the Software without restriction, including without limitation the rights to +# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +# of the Software, and to permit persons to whom the Software is furnished to do +# so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# The Software shall be used for Good, not Evil. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# */ + +from StringIO import StringIO + +def jsmin(js): + ins = StringIO(js) + outs = StringIO() + JavascriptMinify().minify(ins, outs) + str = outs.getvalue() + if len(str) > 0 and str[0] == '\n': + str = str[1:] + return str + +def isAlphanum(c): + """return true if the character is a letter, digit, underscore, + dollar sign, or non-ASCII character. + """ + return ((c >= 'a' and c <= 'z') or (c >= '0' and c <= '9') or + (c >= 'A' and c <= 'Z') or c == '_' or c == '$' or c == '\\' or (c is not None and ord(c) > 126)); + +class UnterminatedComment(Exception): + pass + +class UnterminatedStringLiteral(Exception): + pass + +class UnterminatedRegularExpression(Exception): + pass + +class JavascriptMinify(object): + + def _outA(self): + self.outstream.write(self.theA) + def _outB(self): + self.outstream.write(self.theB) + + def _get(self): + """return the next character from stdin. Watch out for lookahead. If + the character is a control character, translate it to a space or + linefeed. + """ + c = self.theLookahead + self.theLookahead = None + if c == None: + c = self.instream.read(1) + if c >= ' ' or c == '\n': + return c + if c == '': # EOF + return '\000' + if c == '\r': + return '\n' + return ' ' + + def _peek(self): + self.theLookahead = self._get() + return self.theLookahead + + def _next(self): + """get the next character, excluding comments. peek() is used to see + if a '/' is followed by a '/' or '*'. + """ + c = self._get() + if c == '/': + p = self._peek() + if p == '/': + c = self._get() + while c > '\n': + c = self._get() + return c + if p == '*': + c = self._get() + while 1: + c = self._get() + if c == '*': + if self._peek() == '/': + self._get() + return ' ' + if c == '\000': + raise UnterminatedComment() + + return c + + def _action(self, action): + """do something! What you do is determined by the argument: + 1 Output A. Copy B to A. Get the next B. + 2 Copy B to A. Get the next B. (Delete A). + 3 Get the next B. (Delete B). + action treats a string as a single character. Wow! + action recognizes a regular expression if it is preceded by ( or , or =. + """ + if action <= 1: + self._outA() + + if action <= 2: + self.theA = self.theB + if self.theA == "'" or self.theA == '"': + while 1: + self._outA() + self.theA = self._get() + if self.theA == self.theB: + break + if self.theA <= '\n': + raise UnterminatedStringLiteral() + if self.theA == '\\': + self._outA() + self.theA = self._get() + + + if action <= 3: + self.theB = self._next() + if self.theB == '/' and (self.theA == '(' or self.theA == ',' or + self.theA == '=' or self.theA == ':' or + self.theA == '[' or self.theA == '?' or + self.theA == '!' or self.theA == '&' or + self.theA == '|' or self.theA == ';' or + self.theA == '{' or self.theA == '}' or + self.theA == '\n'): + self._outA() + self._outB() + while 1: + self.theA = self._get() + if self.theA == '/': + break + elif self.theA == '\\': + self._outA() + self.theA = self._get() + elif self.theA <= '\n': + raise UnterminatedRegularExpression() + self._outA() + self.theB = self._next() + + + def _jsmin(self): + """Copy the input to the output, deleting the characters which are + insignificant to JavaScript. Comments will be removed. Tabs will be + replaced with spaces. Carriage returns will be replaced with linefeeds. + Most spaces and linefeeds will be removed. + """ + self.theA = '\n' + self._action(3) + + while self.theA != '\000': + if self.theA == ' ': + if isAlphanum(self.theB): + self._action(1) + else: + self._action(2) + elif self.theA == '\n': + if self.theB in ['{', '[', '(', '+', '-']: + self._action(1) + elif self.theB == ' ': + self._action(3) + else: + if isAlphanum(self.theB): + self._action(1) + else: + self._action(2) + else: + if self.theB == ' ': + if isAlphanum(self.theA): + self._action(1) + else: + self._action(3) + elif self.theB == '\n': + if self.theA in ['}', ']', ')', '+', '-', '"', '\'']: + self._action(1) + else: + if isAlphanum(self.theA): + self._action(1) + else: + self._action(3) + else: + self._action(1) + + def minify(self, instream, outstream): + self.instream = instream + self.outstream = outstream + self.theA = '\n' + self.theB = None + self.theLookahead = None + + self._jsmin() + self.instream.close() + +if __name__ == '__main__': + import sys + jsm = JavascriptMinify() + jsm.minify(sys.stdin, sys.stdout)