tailwind-0.2.0.0: src/Web/Tailwind.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TemplateHaskell #-}
-- | Tailwind runner in Haskell
--
-- TODO: Add `main` with CLI parser.
-- > tailwind-run [-w] 'src/**/*.hs'
module Web.Tailwind
( -- * Runner
runTailwind,
-- * Types
TailwindConfig (..),
Tailwind (..),
Mode (..),
tailwindConfig,
tailwindInput,
tailwindOutput,
tailwindConfigContent,
tailwindMode,
)
where
import Control.Lens.TH (makeLenses)
import Control.Monad.Logger (MonadLogger, logInfoN)
import Data.Aeson (encode)
import Data.ByteString (hPut)
import Data.Default (Default (def))
import Deriving.Aeson
import NeatInterpolation (text)
import System.CPUTime (getCPUTime)
import System.Directory (doesFileExist)
import System.IO (hClose)
import System.Which (staticWhich)
import Text.Printf (printf)
import qualified Text.Show
import UnliftIO (MonadUnliftIO, finally)
import UnliftIO.Directory (removeFile)
import UnliftIO.Process (callProcess)
import UnliftIO.Temporary (withSystemTempFile)
-- | Haskell version of tailwind.config.js
--
-- Only the subset we care to define, as some fields (eg: plugins) are defined
-- with arbitrary JS code.
data TailwindConfig = TailwindConfig
{ -- | List of source patterns that reference CSS classes
_tailwindConfigContent :: [FilePath]
}
deriving (Generic)
deriving
(ToJSON)
via CustomJSON
'[ FieldLabelModifier
'[StripPrefix "_tailwindConfig", CamelToSnake]
]
TailwindConfig
newtype Css = Css {unCss :: Text}
data Mode = JIT | Production
deriving (Eq, Show)
data Tailwind = Tailwind
{ _tailwindConfig :: TailwindConfig,
_tailwindInput :: Css,
_tailwindOutput :: FilePath,
_tailwindMode :: Mode
}
makeLenses ''TailwindConfig
makeLenses ''Tailwind
instance Default TailwindConfig where
def =
TailwindConfig
{ _tailwindConfigContent = []
}
instance Default Tailwind where
def =
Tailwind
{ _tailwindConfig = def,
_tailwindInput = def,
_tailwindOutput = "tailwind.css",
_tailwindMode = JIT
}
instance Default Css where
def =
Css
[text|
@tailwind base;
@tailwind components;
@tailwind utilities;
|]
instance Text.Show.Show TailwindConfig where
show (decodeUtf8 . encode -> config) =
-- Use `Object.assign` to merge JSON (produced in Haskell) with the rest of
-- config (defined by raw JS; that cannot be JSON encoded)
toString
[text|
module.exports =
Object.assign(
${config},
{
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography'),
require('@tailwindcss/forms'),
require('@tailwindcss/line-clamp'),
require('@tailwindcss/aspect-ratio')
],
})
|]
tailwind :: FilePath
tailwind = $(staticWhich "tailwind")
modeArgs :: Mode -> [String]
modeArgs = \case
JIT -> ["-w"]
Production -> ["--minify"]
runTailwind :: (MonadUnliftIO m, MonadLogger m, HasCallStack) => Tailwind -> m ()
runTailwind Tailwind {..} = do
withTmpFile (show _tailwindConfig) $ \configFile ->
withTmpFile (unCss _tailwindInput) $ \inputFile ->
let f = bool id (failIfFileNotCreated _tailwindOutput) (_tailwindMode == Production)
in f $ callTailwind $ ["-c", configFile, "-i", inputFile, "-o", _tailwindOutput] <> modeArgs _tailwindMode
when (_tailwindMode == JIT) $
error "Tailwind exited unexpectedly!"
withTmpFile :: MonadUnliftIO m => Text -> (FilePath -> m a) -> m a
withTmpFile s f = do
withSystemTempFile "ema-tailwind-tmpfile" $ \fp h -> do
liftIO $ do
putStrLn $ "$ cat " <> fp
putTextLn s
hPut h (encodeUtf8 s) >> hClose h
f fp
`finally` removeFile fp
-- Workaround for https://github.com/srid/emanote/issues/232
--
-- If the given IO action doesnot create this file (we remove the file before
-- running the IO action), then fail with `error`.
failIfFileNotCreated :: (MonadUnliftIO m, HasCallStack) => FilePath -> m a -> m a
failIfFileNotCreated fp m = do
liftIO (doesFileExist fp) >>= \case
True -> removeFile fp
_ -> pure ()
x <- m
exists <- liftIO $ doesFileExist fp
if exists
then pure x
else error $ "File not created: " <> toText fp
callTailwind :: (MonadIO m, MonadLogger m) => [String] -> m ()
callTailwind args = do
logInfoN $ "Running Tailwind compiler with args: " <> show args
liftIO (doesFileExist tailwind) >>= \case
True ->
timeIt $ do
callProcess tailwind args
False ->
error $ "Tailwind compiler not found at " <> toText tailwind
timeIt :: MonadIO m => m b -> m b
timeIt m = do
t0 <- liftIO getCPUTime
!x <- m
t1 <- liftIO getCPUTime
let diff :: Double = fromIntegral (t1 - t0) / (10 ^ (9 :: Integer))
liftIO $ printf "Process duration: %0.3f ms\n" diff
pure $! x