packages feed

css-class-bindings (empty) → 0.0.2

raw patch · 13 files changed

+443/−0 lines, 13 filesdep +QuickCheckdep +basedep +containers

Dependencies added: QuickCheck, base, containers, css-class-bindings, css-syntax, directory, filepath, tasty, tasty-discover, tasty-hunit, tasty-quickcheck, template-haskell, text

Files

+ LICENSE view
@@ -0,0 +1,32 @@+This module is under this "3 clause" BSD license:++Copyright (c) 2025-2025, Daniil Iaitskov+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are+met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * The names of the contributors may not be used to endorse or+      promote products derived from this software without specific+      prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ changelog.md view
@@ -0,0 +1,7 @@+# css-class-bindings changelog++## Version 0.0.2 2026-03-29+  * includeCss++## Version 0.0.1 2026-03-28+  * init
+ css-class-bindings.cabal view
@@ -0,0 +1,152 @@+cabal-version: 3.0+name:          css-class-bindings+version:       0.0.2++synopsis:      generates Haskell bindings for CSS classes+description:+    == Motivation+    #motivation#+    +    Recently I migrated the+    <https://github.com/yaitskov/vpn-router vpn-router> frontend to+    <https://github.com/dmjio/miso Miso>, I noticed that DOM functions+    (e.g. @div_@) accept CSS class names as plain strings. This prevents GHC+    from catching typos in referenced names, even if stylesheets are correct+    and defined with <https://hackage.haskell.org/package/clay clay>.+    +    == Usage+    #usage#+    +    The library leverages the power of TH to parse CSS snippets from quasi+    quotes or style files and to define Haskell constants for every class+    mentioned in the input.+    +    === Quasi-quote input+    #quasi-quote-input#+    +    > {-# LANGUAGE QuasiQuotes #-}+    > module Css where+    > import CssClassBindings ( css )+    >+    > [css|+    > .foo-bar {+    >   color: #fc2c2c;+    > }+    > |]+    +    > module Main where+    >+    > import Css (fooBar, cssAsLiteralText)+    > import CssClassBindings qualified as C+    > import Miso+    > import Miso.Html.Element (div_, button_)+    > import Miso.Html.Property qualified as P+    >+    > class_ :: C.CssClass MisoString -> Attribute action+    > class_ = P.class_ . C.class_+    >+    > app :: App Model Action+    > app = (component emptyModel updateModel viewModel)+    >   { styles = [ Style cssAsLiteralText ]+    >   }+    >+    > viewModel :: Model -> View Model Action+    > viewModel m = div_ [] [ button_ [ class_ fooBar ] [ "Submit" ] ]+    +    The library has been created to improve a miso-based app, but it does+    not depend on miso and it can be used in other setups.+    +    > fooBar :: IsString s => CssClass s+    > cssAsLiteralText :: IsString s => s+    +    === File input+    #file-input#+    +    > {-# LANGUAGE TemplateHaskell #-}+    > module Css where+    > import CssClassBindings ( includeCss )+    >+    > includeCss "assets/style.css"+    +    > module Main where+    >+    > import Css (fooBar, style)+    > -- ...+    +    == Development environment+    #development-environment#+    +    HLS should be available inside the default dev shell.+    +    > $ nix develop+    > $ emacs src/*/*/Qq.hs &+    > $ cabal build+homepage:      http://github.com/yaitskov/css-class-bindings+license:       BSD-3-Clause+license-file:  LICENSE+author:        Daniil Iaitskov+maintainer:    dyaitskov@gmail.com+copyright:     Daniil Iaitkov 2026+category:      System+build-type:    Simple+bug-reports:   https://github.com/yaitskov/css-class-bindings/issues+extra-doc-files:+  changelog.md+tested-with:+  GHC == 9.12.2++source-repository head+  type:+    git+  location:+    https://github.com/yaitskov/css-class-bindings.git++common base+  default-language: GHC2024+  ghc-options: -Wall+  default-extensions:+    DefaultSignatures+    NoImplicitPrelude+    OverloadedStrings+    TemplateHaskell+  build-depends:+    , base >=4.7 && < 5+    , template-haskell  < 3++library+  import: base+  hs-source-dirs: src+  exposed-modules:+    CssClassBindings+  other-modules:+    CssClassBindings.Escape+    CssClassBindings.IncludeCss+    CssClassBindings.Qq+  build-depends:+    , directory          < 2+    , containers         < 1+    , css-syntax         < 1+    , filepath           < 2+    , template-haskell   < 3+    , text               < 3++test-suite test+  import: base+  type: exitcode-stdio-1.0+  main-is: Driver.hs+  other-modules:+    CssClassBindings.Test.IncludeCssDefs+    CssClassBindings.Test.IncludeCssAsserts+    CssClassBindings.Test.QqDefs+    CssClassBindings.Test.QqAsserts+    Discovery+  hs-source-dirs:+    test+  ghc-options: -Wall -rtsopts -threaded -main-is Driver+  build-depends:+    , css-class-bindings+    , QuickCheck+    , tasty+    , tasty-discover+    , tasty-hunit+    , tasty-quickcheck
+ src/CssClassBindings.hs view
@@ -0,0 +1,5 @@+-- | Module provides a quasi quoter translating CSS classes to Haskell functions+module CssClassBindings (class_, css, CssClass, cssToDecs, includeCss) where++import CssClassBindings.Qq ( class_, css, cssToDecs, CssClass )+import CssClassBindings.IncludeCss ( includeCss )
+ src/CssClassBindings/Escape.hs view
@@ -0,0 +1,35 @@+module CssClassBindings.Escape where++import Data.Char+    ( isAlpha, isAlphaNum, isUpper, toLower, toUpper )+import Prelude++escapeIden :: String -> String+escapeIden s =+  case escapeIdenChar <$> hyphensToCamelCase s of+    s'@(fl:ol)+      | not (isAlpha fl) -> '_' : s'+      | isUpper fl -> toLower fl : ol+      | otherwise -> s'+    [] -> []++escapeIdenChar :: Char -> Char+escapeIdenChar c+  | isAlphaNum c || c == '_' = c+  | otherwise = '_'++hyphensToCamelCase :: String -> String+hyphensToCamelCase = concatMap ucFirst . splitOn '-'++ucFirst :: String -> String+ucFirst "" = ""+ucFirst (h:t) = toUpper h : t++splitOn :: Eq a => a -> [a] -> [[a]]+splitOn x xs = go xs []+  where+    go [] acc = [reverse acc]+    go (y : ys) acc =+      if x == y+      then reverse acc : go ys []+      else go ys (y : acc)
+ src/CssClassBindings/IncludeCss.hs view
@@ -0,0 +1,21 @@+module CssClassBindings.IncludeCss (includeCss) where++import CssClassBindings.Escape ( escapeIden )+import CssClassBindings.Qq (cssToDecs)+import Data.Text.IO.Utf8 qualified as U+import Language.Haskell.TH.Syntax+    ( mkName, Q, Dec, runIO, getPackageRoot )+import Prelude+import System.Directory (makeAbsolute)+import System.FilePath (takeBaseName, (</>))++liftIO1 :: (a -> IO b) -> a -> Q b+liftIO1 f x = runIO (f x)++-- | like css quasi quoter but+-- css input is exported via constant equal to base file name+-- instead of cssAsLiteralText.+includeCss :: FilePath -> Q [Dec]+includeCss p = do+  ap <- getPackageRoot >>= liftIO1 makeAbsolute . (</> p)+  cssToDecs (mkName (escapeIden $ takeBaseName p)) <$> runIO (U.readFile ap)
+ src/CssClassBindings/Qq.hs view
@@ -0,0 +1,123 @@+-- | Module provides a quasi quoter translating CSS classes to Haskell functions+module CssClassBindings.Qq (CssClass, cssToDecs, css, class_) where++import CssClassBindings.Escape ( escapeIden )+import Data.CSS.Syntax.Tokens ( tokenize, Token(Ident, Delim) )+import Data.Set ( insert, Set, toList )+import Data.String ( IsString )+import Data.Text ( Text, pack, unpack )+import Language.Haskell.TH.Quote ( QuasiQuoter(..) )+import Language.Haskell.TH.Syntax+    ( mkName,+      Name,+      Exp(LitE, AppE, ConE),+      Clause(Clause),+      Type(VarT, ForallT, AppT, ConT),+      Dec(PragmaD, SigD, FunD),+      Body(NormalB),+      Inline(Inline),+      Lit(StringL),+      Phases(AllPhases),+      Pragma(InlineP),+      RuleMatch(FunLike),+      Specificity(InferredSpec),+      TyVarBndr(PlainTV) )++import Prelude++newtype CssClass s = CssClass { unCssClass :: s } deriving (Show, Eq, Ord)++instance (Semigroup s, IsString s) => Semigroup (CssClass s) where+  CssClass l <> CssClass r = CssClass $ l <> " " <> r++class_ :: IsString s => CssClass s -> s+class_ = unCssClass++{- | quasi quoter accepts CSS and generates definition for classes++> .foo-bar {+>   padding: 0px;+> }++is expanded as:++@+{-# INLINE fooBar #-}+fooBar :: IsString s => CssClass s+fooBar = "foo-bar"+{-# INLINE cssAsLiteralText #-}+cssAsLiteralText :: IsString s => s+cssAsLiteralText = ".foo-bar { padding: 0px; }"+@++-}+css :: QuasiQuoter+css = QuasiQuoter+  { quoteExp  = \_ -> fail "quoteExp: not implemented"+  , quotePat  = \_ -> fail "quotePat: not implemented"+  , quoteType = \_ -> fail "quoteType: not implemented"+  , quoteDec  = pure . cssToDecs n . pack+  }+  where+    n = mkName "cssAsLiteralText"++{- Sample of token stream+Delim '.',Ident "skeleton-block",Colon,Function "not",Colon,Ident "last-child",RightParen+-}+collectReferedClasses :: Set (CssClass Text) -> [Token] -> Set (CssClass Text)+collectReferedClasses s = \case+  Delim '.' : Ident cn : t ->+    collectReferedClasses (insert (CssClass cn) s) t+  _ : t -> collectReferedClasses s t+  [] -> s++{- | generate definition like:++@@+  {-# INLINE foo #-}+  foo :: IsString s => CssClass s+  foo = "foo"+@@++-}+cssClassConstDec :: CssClass Text -> [Dec]+cssClassConstDec (CssClass cn) =+  [ PragmaD (InlineP n Inline FunLike AllPhases)+  , SigD n+    (ForallT+      [PlainTV st InferredSpec]+      [AppT (ConT ''IsString) (VarT st)]+      (AppT (ConT ''CssClass) (VarT st)))+  , FunD n [ Clause [] body [] ]+  ]+  where+    st = mkName "s"+    ns = unpack cn+    n = mkName $ escapeIden ns+    body = NormalB (AppE (ConE 'CssClass) (LitE (StringL ns)))++cssToDecs :: Name -> Text -> [Dec]+cssToDecs inputExportName s = go s+  where+    go = (cssAsLiteralTextD inputExportName s <>) . concatMap cssClassConstDec . toList . collectReferedClasses mempty . tokenize++{- | generate definition like:+@@+  {-# INLINE cssAsLiteralText #-}+  cssAsLiteralText :: IsString s => s+  cssAsLiteralText = s+@@+-}+cssAsLiteralTextD :: Name -> Text -> [Dec]+cssAsLiteralTextD n s =+  [ SigD n+    (ForallT+      [PlainTV st InferredSpec]+      [AppT (ConT ''IsString) (VarT st)]+      (VarT st))+  , FunD n [ Clause [] body [] ]+  , PragmaD (InlineP n Inline FunLike AllPhases)+  ]+  where+    st = mkName "s"+    body = NormalB (LitE (StringL $ unpack s))
+ test/CssClassBindings/Test/IncludeCssAsserts.hs view
@@ -0,0 +1,20 @@+{-# LANGUAGE MultilineStrings #-}+module CssClassBindings.Test.IncludeCssAsserts where++import CssClassBindings (class_)+import CssClassBindings.Test.IncludeCssDefs (fooBar, style)+import Prelude+import Test.Tasty.HUnit ( (@=?) )++unit_camelCaseLiteral :: IO ()+unit_camelCaseLiteral = ("foo-bar" :: String) @=? class_ fooBar++unit_exportCssInputAsIs :: IO ()+unit_exportCssInputAsIs = css @=? style+  where+    css :: String+    css = """+      .foo-bar {+        color: #1212ff;+      }+    """ <> "\n"
+ test/CssClassBindings/Test/IncludeCssDefs.hs view
@@ -0,0 +1,6 @@+{-# LANGUAGE TemplateHaskell #-}+module CssClassBindings.Test.IncludeCssDefs where++import CssClassBindings (includeCss)++includeCss "test/style.css"
+ test/CssClassBindings/Test/QqAsserts.hs view
@@ -0,0 +1,20 @@+{-# LANGUAGE MultilineStrings #-}+module CssClassBindings.Test.QqAsserts where++import CssClassBindings (class_)+import CssClassBindings.Test.QqDefs (fooBar, cssAsLiteralText)+import Prelude+import Test.Tasty.HUnit ( (@=?) )++unit_camelCaseLiteral :: IO ()+unit_camelCaseLiteral = ("foo-bar" :: String) @=? class_ fooBar++unit_exportCssInputAsIs :: IO ()+unit_exportCssInputAsIs = css @=? cssAsLiteralText+  where+    css :: String+    css = """+      .foo-bar {+        color: #1212ff;+      }+    """
+ test/CssClassBindings/Test/QqDefs.hs view
@@ -0,0 +1,8 @@+{-# LANGUAGE QuasiQuotes #-}+module CssClassBindings.Test.QqDefs where++import CssClassBindings (css)++[css|.foo-bar {+  color: #1212ff;+}|]
+ test/Discovery.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF tasty-discover -optF --generated-module=Discovery #-}
+ test/Driver.hs view
@@ -0,0 +1,13 @@+module Driver where++import qualified Discovery+import Prelude+import Test.Tasty ( defaultMain, testGroup, TestTree )++main :: IO ()+main = defaultMain =<< testTree+  where+    testTree :: IO TestTree+    testTree = do+      tests <- Discovery.tests+      pure $ testGroup "css-class-bindings" [ tests ]