--------------------------------------------------------------------------------
-- | Module used for CSS compression. The compression is currently in a simple
-- state, but would typically reduce the number of bytes by about 25%.
module Hakyll.Web.CompressCss
    ( compressCssCompiler
    , compressCss
    ) where


--------------------------------------------------------------------------------
import           Data.Char               (isSpace)
import           Data.List               (dropWhileEnd, isPrefixOf)


--------------------------------------------------------------------------------
import           Hakyll.Core.Compiler
import           Hakyll.Core.Item
import           Hakyll.Core.Util.String


--------------------------------------------------------------------------------
-- | Compiler form of 'compressCss'
compressCssCompiler :: Compiler (Item String)
compressCssCompiler = fmap compressCss <$> getResourceString


--------------------------------------------------------------------------------
-- | Compress CSS to speed up your site.
compressCss :: String -> String
compressCss = withoutStrings (handleCalcExpressions compressSeparators . compressWhitespace)
            . dropWhileEnd isSpace
            . dropWhile isSpace
            . stripComments


--------------------------------------------------------------------------------
-- | Compresses certain forms of separators.
compressSeparators :: String -> String
compressSeparators =
    replaceAll "; *}" (const "}") .
    replaceAll ";+" (const ";") .
    replaceAll " *[{};,>+~!] *" (take 1 . dropWhile isSpace) .
    replaceAll ": *" (take 1) -- not destroying pseudo selectors (#323)

-- | Uses `compressCalcExpression` on all parenthesised calc expressions
-- and applies `transform` to all parts outside of them
handleCalcExpressions :: (String -> String) -> String -> String
handleCalcExpressions transform = top transform
  where
    top f ""                             = f ""
    top f str | "calc(" `isPrefixOf` str = f "calc" ++ nested 0 compressCalcExpression (drop 4 str)
    top f (x:xs)                         = top (f . (x:)) xs

    -- when called with depth=0, the first character must be a '('
    nested :: Int -> (String -> String) -> String -> String
    nested _     f ""                             = f "" -- shouldn't happen, mismatched nesting
    nested depth f str | "calc(" `isPrefixOf` str = nested depth f (drop 4 str)
    nested 1     f (')':xs)                       = f ")" ++ top transform xs
    nested depth f (x:xs)                         = nested (case x of
                                                      '(' -> depth + 1
                                                      ')' -> depth - 1 -- assert: depth > 1
                                                      _   -> depth
                                                    ) (f . (x:)) xs

-- | does not remove whitespace around + and -, which is important in calc() expressions
compressCalcExpression :: String -> String
compressCalcExpression =
    replaceAll " *[*/] *| *\\)|\\( *" (take 1 . dropWhile isSpace)

--------------------------------------------------------------------------------
-- | Compresses all whitespace.
compressWhitespace :: String -> String
compressWhitespace = replaceAll "[ \t\n\r]+" (const " ")

--------------------------------------------------------------------------------
-- | Function that strips CSS comments away (outside of strings).
stripComments :: String -> String
stripComments ""                       = ""
stripComments ('/':'*':str)            = stripComments $ eatComment str
stripComments (x:xs) | x `elem` "\"'"  = retainString x xs stripComments
                     | otherwise       = x : stripComments xs

eatComment :: String -> String
eatComment "" = ""
eatComment ('*':'/':str) = str
eatComment (_:str) = eatComment str


--------------------------------------------------------------------------------
-- | Helper functions to handle string tokens correctly.

-- TODO: handle backslash escapes
withoutStrings :: (String -> String) -> String -> String
withoutStrings f str = case span (`notElem` "\"'") str of
    (text, "")     -> f text
    (text, d:rest) -> f text ++ retainString d rest (withoutStrings f)

retainString :: Char -> String -> (String -> String) -> String
retainString delim str cont = case span (/= delim) str of
    (val, "")     -> delim : val
    (val, _:rest) -> delim : val ++ delim : cont rest