Module:Cssmin

From Meta, a Wikimedia project coordination wiki
Module documentation
--[=[
	A simple module for Scribunto that minifies CSS
	Ported by [[User:Mxn]]
	Tweaked to more closely match the output of http://cssminifier.com/
	
	node-cssmin
	https://github.com/jbleuzen/node-cssmin
	A simple module for Node.js that minify CSS
	Author : Johan Bleuzen
	cssmin is released under a "BSD License":
	http://opensource.org/licenses/bsd-license.php.
	
	cssmin.js
	Author: Stoyan Stefanov - http://phpied.com/
	This is a JavaScript port of the CSS minification tool
	distributed with YUICompressor, itself a port
	of the cssmin utility by Isaac Schlueter - http://foohack.com/
	Permission is hereby granted to use the JavaScript version under the same
	conditions as the YUICompressor (original YUICompressor note below).
	
	YUI Compressor
	http://developer.yahoo.com/yui/compressor/
	Author: Julien Lecomte - http://www.julienlecomte.net/
	Copyright (c) 2011 Yahoo! Inc. All rights reserved.
	The copyrights embodied in the content of this file are licensed
	by Yahoo! Inc. under the BSD (revised) open source license.
]=]--

local p = {}
local yesno = require "Module:Yesno"

function p.cssmin(css, linebreakpos, pretty)
	local frame
	if type(css) == "table" then
		frame, css, linebreakpos, pretty = css, css.args[1] or "", tonumber(css.args.linebreakpos), yesno(css.args.pretty)
	else
		frame = mw.getCurrentFrame()
	end
	
	local startIndex = 1
	local endIndex = 1
	local preservedSingleTokens = {}
	local numPreservedSingleTokens = 0
	local preservedDoubleTokens = {}
	local numPreservedDoubleTokens = 0
	local comments = {}
	local numcomments = 0
	local token = ""
	local totallen = mw.ustring.len(css)
	linebreakpos = linebreakpos or 0
	
	-- collect all comment blocks...
	while true do
		startIndex = mw.ustring.find(css, "/*", startIndex, true)
		if not startIndex then break end
		
		endIndex = mw.ustring.find(css, "*/", startIndex + 2, true)
		if not endIndex then
			endIndex = totallen - 1
		end
		token = mw.ustring.sub(css, startIndex + 2, endIndex)
		table.insert(comments, token)
		numcomments = numcomments + 1
		css = mw.ustring.format("%s___YUICSSMIN_PRESERVE_CANDIDATE_COMMENT_%i___%s",
			mw.ustring.sub(css, 1, startIndex + 1),
			numcomments,
			mw.ustring.sub(css, endIndex))
		startIndex = startIndex + 2
	end
	
	-- preserve strings so their content doesn't get accidentally minified
	-- TODO: Skip past escaped " and ' in strings.
	local preserveStrings = function (match, tokens, oldnumtokens, kind)
		local i
		local max
		local quote = mw.ustring.sub(match, 1, 1)
		
		match = mw.ustring.sub(match, 2, -2)
		
		-- maybe the string contains a comment-like substring?
		-- one, maybe more? put'em back then
		if mw.ustring.find(match, "___YUICSSMIN_PRESERVE_CANDIDATE_COMMENT_") then
			for i, comment in ipairs(comments) do
				match = mw.ustring.gsub(match, "___YUICSSMIN_PRESERVE_CANDIDATE_COMMENT_" .. i .. "___", comment)
			end
		end
		
		-- minify alpha opacity in filter strings
		-- TODO: Ignore case.
		match = mw.ustring.gsub(match, "progid:DXImageTransform%.Microsoft%.Alpha%(Opacity=", "alpha(opacity=", 1)
		
		table.insert(tokens, match)
		return string.format("%s___YUICSSMIN_PRESERVED_%s_TOKEN_%i___%s", quote, kind:upper(), oldnumtokens + 1, quote)
	end
	-- TODO: Single-quotes inside double-quoted strings and vice-versa.
	css = mw.ustring.gsub(css, "%b''", function (match)
		local token = preserveStrings(match, preservedSingleTokens, numPreservedSingleTokens, "single")
		numPreservedSingleTokens = numPreservedSingleTokens + 1
		return token
	end)
	css = mw.ustring.gsub(css, '%b""', function (match)
		local token = preserveStrings(match, preservedDoubleTokens, numPreservedDoubleTokens, "double")
		numPreservedDoubleTokens = numPreservedDoubleTokens + 1
		return token
	end)
	
	-- strings are safe, now wrestle the comments
	for i, token in ipairs(comments) do
		-- UNSUPPORTED: ! in the first position of the comment means preserve
		
		-- UNSUPPORTED: \ in the last position looks like hack for Mac/IE5
		
		-- UNSUPPORTED: keep empty comments after child selectors (IE7 hack)
		-- e.g. html >/**/ body
		
		-- in all other cases kill the comment
		css = mw.ustring.gsub(css, "/%*___YUICSSMIN_PRESERVE_CANDIDATE_COMMENT_" .. i .. "___%*/", "")
	end
	
	-- Normalize all whitespace strings to single spaces. Easier to work with that way.
	css = mw.ustring.gsub(css, "%s+", " ")
	
	-- Remove the spaces before the things that should not have spaces before them.
	-- But, be careful not to turn "p :link {...}" into "p:link{...}"
	-- Swap out any pseudo-class colons with the token, and then swap back.
	local insertPseudoClassColons = function (m)
		m = mw.ustring.gsub(m, ":", "___YUICSSMIN_PSEUDOCLASSCOLON___")
		-- Remove spaces around relational selectors
		m = mw.ustring.gsub(m, "%s*([~%+>])%s*", "%1")
		return m
	end
	css = mw.ustring.gsub(css, "^[^{]+", insertPseudoClassColons)
	css = mw.ustring.gsub(css, "}[^{]+", insertPseudoClassColons)
	
	-- Preserve spaces in calc expressions
	css = mw.ustring.gsub(css, "(calc%s*%(%s*(.-)%s*%))", function (m, c)
		return mw.ustring.gsub(m, (mw.ustring.gsub(c, "%%", "%%%%")),
			mw.ustring.gsub(c, "%s+", "___YUICSSMIN_SPACE_IN_CALC___"))
	end)
	
	css = mw.ustring.gsub(css, "%s+([!{};:>+()%],])", "%1")
	css = mw.ustring.gsub(css, "___YUICSSMIN_PSEUDOCLASSCOLON___", ":")
	
	-- retain space for special IE6 cases
	css = mw.ustring.gsub(css, ":first%-line([{,])", ":first-line %1")
	css = mw.ustring.gsub(css, ":first%-letter([{,])", ":first-letter %1")
	
	-- no space after the end of a preserved comment
	css = mw.ustring.gsub(css, "%*/ ", "*/")
	
	-- UNSUPPORTED: If there is a @charset, then only allow one, and push to the top of the file.
	
	-- Put the space back in some cases, to support stuff like
	-- @media screen and (-webkit-min-device-pixel-ratio:0){
	css = mw.ustring.gsub(css, "%f[%w]and%(", "and (")
	
	-- Remove the spaces after the things that should not have spaces after them.
	css = mw.ustring.gsub(css, "([!{}:;>+%(%[,])%s+", "%1")
	
	-- Restore preserved spaces in calc expressions
	css = mw.ustring.gsub(css, "___YUICSSMIN_SPACE_IN_CALC___", " ")
	
	-- remove unnecessary semicolons
	css = mw.ustring.gsub(css, ";+}", "}")
	
	-- Replace 0(px,em,%) with 0.
	css = mw.ustring.gsub(css, "([%s:])(0)(%w%w)", function (pre, zero, unit)
		if unit == "px" or unit == "em" or unit == "in" or unit == "cm"
				or unit == "mm" or unit == "pc" or unit == "pt"
				or unit == "ex" then
			return pre .. zero
		end
	end)
	css = mw.ustring.gsub(css, "([%s:])(0)%%", "%1%2")
	
	-- Replace 0 0 0 0; with 0.
	css = mw.ustring.gsub(css, ":0 0 0 0([;}])", ":0%1")
	css = mw.ustring.gsub(css, ":0 0 0([;}])", ":0%1")
	css = mw.ustring.gsub(css, ":0 0([;}])", ":0%1")
	
	-- Replace background-position:0; with background-position:0 0;
	-- same for transform-origin
	css = mw.ustring.gsub(css, "([%w%-]+):0([;}])", function (prop, tail)
		if prop == "background-position" or prop == "transform-origin"
				or prop == "webkit-transform-origin"
				or prop == "moz-transform-origin"
				or prop == "o-transform-origin"
				or prop == "ms-transform-origin" then
			return string.format("%s:0 0%s", prop:lower(), tail)
		end
	end)
	
	-- Replace 0.6 to .6, but only when preceded by : or a white-space
	css = mw.ustring.gsub(css, "([:%s])0+%.(%d+)", "%1.%2")
	
	-- Shorten font-weight: bold to font-weight: 700
	css = mw.ustring.gsub(css, "font%-weight:%s*bold", "font-weight:700")
	
	-- Shorten colors from rgb(51,102,153) to #336699
	-- This makes it more likely that it'll get further compressed in the next step.
	-- TODO: Ignore case.
	css = mw.ustring.gsub(css, "rgb%s*%(%s*([0-9,%s]+)%s*%)", function (rgbcolors)
		rgbcolors = mw.text.split(rgbcolors, ",", true)
		for i, color in ipairs(rgbcolors) do
			rgbcolors[i] = string.format("%x", tonumber(color, 10))
			if #rgbcolors[i] == 1 then
				rgbcolors[i] = "0" .. rgbcolors[i]
			end
		end
		return "#" .. table.concat(rgbcolors, "")
	end)
	
	-- Shorten colors from #AABBCC to #ABC. Note that we want to make sure
	-- the color is not preceded by either ", " or =. Indeed, the property
	--     filter: chroma(color="#FFFFFF");
	-- would become
	--     filter: chroma(color="#FFF");
	-- which makes the filter break in IE.
	css = mw.ustring.gsub(css, "(([^\"'=%s])(%s*)#(%x)(%x)(%x)(%x)(%x)(%x))", function (all, pre, space, r1, r2, g1, g2, b1, b2)
		if r1:lower() == r2:lower()
				and g1:lower() == g2:lower()
				and b1:lower() == b2:lower() then
			return string.format("%s%s#%s%s%s", pre, space, r1, g1, b1):lower()
		end
		return all:lower()
	end)
	
	-- border: none -> border:0
	-- TODO: Ignore case.
	css = mw.ustring.gsub(css, "([%w%-]+):none([;}])", function (prop, tail)
		if prop == "border"
				or prop == "border-top" or prop == "border-right"
				or prop == "border-bottom" or prop == "border-right"
				or prop == "outline" or prop == "background" then
			return string.format("%s:0%s", prop:lower(), tail)
		end
	end)
	
	-- shorter opacity IE filter
	-- TODO: Ignore case.
	css = mw.ustring.gsub(css, "progid:DXImageTransform%.Microsoft%.Alpha%(Opacity=", "alpha(opacity=")
	
	-- Remove empty rules.
	css = mw.ustring.gsub(css, "[^%};%{%/]+%{%}", "")
	
	if linebreakpos >= 0 then
		-- Some source control tools don't like it when files containing lines longer
		-- than, say 8000 characters, are checked in. The linebreak option is used in
		-- that case to split long lines after a specific column.
		local startIndex = 1
		local i = 1
		while i <= mw.ustring.len(css) do
			i = i + 1
			if css[i - 1] == "}" and i - startIndex > linebreakpos then
				css = mw.ustring.sub(css, 1, i - 1) .. "\n" .. mw.ustring.sub(css, i)
				startIndex = i
			end
		end
	end
	
	-- Replace multiple semi-colons in a row by a single one
	-- See SF bug #1980989
	css = mw.ustring.gsub(css, ";;+", ";")
	
	-- restore preserved comments and strings
	for i, token in ipairs(preservedSingleTokens) do
		css = mw.ustring.gsub(css, string.format("___YUICSSMIN_PRESERVED_SINGLE_TOKEN_%i___", i), token)
	end
	for i, token in ipairs(preservedDoubleTokens) do
		css = mw.ustring.gsub(css, string.format("___YUICSSMIN_PRESERVED_DOUBLE_TOKEN_%i___", i), token)
	end
	
	-- Unquote url()
	css = mw.ustring.gsub(css, "url%('([^']-)'%)", "url(%1)")
	css = mw.ustring.gsub(css, "url%(\"([^\"]-)\"%)", "url(%1)")
	
	-- Unquote attribute selectors
	local unquoteAttributeSelectors = function (m)
		return mw.ustring.gsub(m, "%[(%a[%w_%-]*)([~|%^$%*]?=)(['\"])(%a%w*)%3%]", "[%1%2%4]")
	end
	css = mw.ustring.gsub(css, "^[^{]+", unquoteAttributeSelectors)
	css = mw.ustring.gsub(css, "}[^{]+", unquoteAttributeSelectors)
	
	-- Trim the final string (for any leading or trailing white spaces)
	css = mw.text.trim(css)
	
	if pretty then
		css = frame:callParserFunction{
			name = "#tag",
			args = {
				"source",
				lang = "css",
				css
			},
		}
	end
	
	return css
end

return p