diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Releases
+
+## pandoc-utils 0.4.0 (2020-5-24)
+
+- Initial public release.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2020 Krasjet
+
+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 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.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,103 @@
+# pandoc-utils
+
+This package contains some useful functions for writing
+[Pandoc](https://pandoc.org/) filters and integrating Pandoc into Haskell
+applications such as [Hakyll](https://jaspervdj.be/hakyll/). It provides a
+composable wrapper for filters acting on nodes of the [Pandoc
+AST](https://hackage.haskell.org/package/pandoc-types/docs/Text-Pandoc-Definition.html).
+
+## Filter conversion/composition
+
+As an example, let us look at the `behead` and `delink` filter from [Pandoc's
+tutorial](https://pandoc.org/filters.html).
+```haskell
+behead :: Block -> Block
+behead (Header n _ xs) | n >= 2 = Para [Emph xs]
+behead x = x
+
+delink :: Inline -> [Inline]
+delink (Link _ txt _) = txt
+delink x = [x]
+```
+
+Since `behead` has type `Block -> Block`, while `delink` has type `Inline ->
+[Inline]`, they are not naturally composable. However, this package provides a
+utility function `mkFilter` to convert them into a `PandocFilter`.
+```haskell
+import Text.Pandoc.Filter.Utils
+
+beheadFilter :: PandocFilter
+beheadFilter = mkFilter behead
+
+delinkFilter :: PandocFilter
+delinkFilter = mkFilter delink
+```
+`PandocFilter` is an alias for `PartialFilter Pandoc`, so you can also have
+`PartialFilter Inline`, etc. There is also a monadic version called
+`PartialFilterM` for encapsulating monadic filters.
+
+The `PandocFilter` is a monoid so you can do something like,
+```haskell
+myFilter :: PandocFilter
+myFilter = beheadFilter <> delinkFilter
+```
+where `myFilter` would apply `beheadFilter` first, then the `delinkFilter`. You
+can apply the filter using `applyFilter`,
+```haskell
+import Text.Pandoc
+import Data.Default (def)
+
+mdToHtml
+  :: Text                    -- ^ Input markdown string
+  -> Either PandocError Text -- ^ Html string or error
+mdToHtml md = runPure $ do
+  doc <- readMarkdown def md
+  let doc' = applyFilter myFilter doc
+  writeHtml5String def doc'
+```
+or get an unwrapped `Pandoc -> Pandoc` filter using `getFilter` (this function
+is also capable of doing implicit conversion from `PartialFilter a` to `b ->
+b`).
+```haskell
+myPandocFilter :: Pandoc -> Pandoc
+myPandocFilter = getFilter myFilter
+```
+
+For applying multiple filters, there is also a function called `applyFilters`,
+which takes a list of filters and apply it to a `Pandoc` document (or subnode)
+sequentially, from left to right.
+```haskell
+myFilters :: [PandocFilter]
+myFilters =
+  [ beheadFilter
+  , delinkFilter
+  ]
+
+mdToHtml'
+  :: Text                    -- ^ Input markdown string
+  -> Either PandocError Text -- ^ Html string or error
+mdToHtml' md = runPure $ do
+  doc <- readMarkdown def md
+  let doc' = applyFilters myFilters doc
+  writeHtml5String def doc'
+```
+
+## Attribute builder
+
+pandoc-utils also provides an attribute builder for handling attributes. You
+can create a new attributes by
+```haskell
+ghci> import Text.Pandoc.Filter.Utils.AttrBuilder
+ghci> import Text.Pandoc.Definition
+ghci> nullAttr `setId` "id" `addClass` "class" `addKVPair` ("key","value")
+("id",["class"],[("key","value")])
+```
+or modifying an existing attributes by
+```haskell
+ghci> attr = ("id",[],[])
+ghci> attr `setId` "newId"
+("newId",[],[])
+```
+
+For more examples, please read the
+[spec](https://github.com/Krasjet/pandoc-utils/blob/master/test/Spec.hs).
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/pandoc-utils.cabal b/pandoc-utils.cabal
new file mode 100644
--- /dev/null
+++ b/pandoc-utils.cabal
@@ -0,0 +1,61 @@
+build-type:          Simple
+cabal-version:       2.0
+
+name:                pandoc-utils
+version:             0.4.0
+synopsis:
+  Utility functions to work with Pandoc in Haskell applications.
+description:
+  This package contains some useful functions for writing [Pandoc](https://pandoc.org/)
+  filters and integrating Pandoc into Haskell applications such
+  as [Hakyll](https://jaspervdj.be/hakyll/). It provides a
+  composable wrapper for filters acting on nodes of the [Pandoc
+  AST](https://hackage.haskell.org/package/pandoc-types/docs/Text-Pandoc-Definition.html).
+  .
+  For more examples, please see the [README](https://github.com/Krasjet/pandoc-utils) on GitHub.
+license:             MIT
+license-file:        LICENSE
+author:              Krasjet
+maintainer:          Krasjet
+copyright:           Copyright (c) 2020 Krasjet
+homepage:            https://github.com/Krasjet/pandoc-utils
+bug-reports:         https://github.com/Krasjet/pandoc-utils/issues
+category:            Text
+extra-source-files:
+    CHANGELOG.md
+    README.md
+
+source-repository head
+  type:     git
+  location: git://github.com/Krasjet/pandoc-utils.git
+
+library
+  hs-source-dirs:      src
+  default-language:    Haskell2010
+  ghc-options:         -Wall
+  exposed-modules:
+      Text.Pandoc.Utils
+      Text.Pandoc.Filter.Utils
+      Text.Pandoc.Filter.Utils.AttrBuilder
+  build-depends:
+      base          >=4.10 && <4.15
+    , pandoc-types ^>=1.17.2 || ^>=1.20
+    , text         ^>=1.2
+
+test-suite pandoc-utils-test
+  hs-source-dirs:      test
+  default-language:    Haskell2010
+  ghc-options:         -threaded -rtsopts -with-rtsopts=-N -Wall
+  type:                exitcode-stdio-1.0
+  main-is:             Spec.hs
+  build-depends:
+      base          >=4.10 && <4.15
+    , pandoc-utils
+    , containers   ^>=0.6
+    , data-default ^>=0.7
+    , pandoc       ^>=2.9
+    , pandoc-types ^>=1.20
+    , transformers ^>=0.5
+    , tasty        ^>=1.2
+    , tasty-hspec  ^>=1.1
+    , text         ^>=1.2
diff --git a/src/Text/Pandoc/Filter/Utils.hs b/src/Text/Pandoc/Filter/Utils.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Filter/Utils.hs
@@ -0,0 +1,180 @@
+{-# LANGUAGE FlexibleContexts      #-}
+{-# LANGUAGE FlexibleInstances     #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+
+-- | This module contains some utility functions to work with different levels
+-- of Pandoc filters. For example, for the conversion from @'Inline' ->
+-- ['Inline']@ to @'Pandoc' -> 'Pandoc'@ filter.
+module Text.Pandoc.Filter.Utils (
+  -- * Definitions
+  PartialFilterM,
+  PartialFilter,
+  PandocFilterM,
+  PandocFilter,
+  -- * Filter application
+  applyFilterM,
+  applyFilter,
+  -- * Filter composition
+  applyFiltersM,
+  applyFilters,
+  -- * Filter conversion
+  getFilterM,
+  getFilter,
+  getConcatedFilterM,
+  getConcatedFilter,
+  ToPartialFilter (..),
+  mkConcatedFilter,
+  toFilterM,
+  ) where
+
+import Control.Monad          ((>=>))
+import Data.Foldable          (fold)
+import Data.Functor.Identity  (Identity (..))
+import Text.Pandoc.Definition
+import Text.Pandoc.Walk
+
+-- | @PartialFilterM m p@ is a wrapper for any monadic @p -> m p@ Pandoc
+-- filters acting on a subnode (e.g. 'Inline' or 'Block') of the 'Pandoc'
+-- abstract syntax tree.
+--
+-- * @m@: a monad.
+-- * @p@: the type of a subnode of 'Pandoc' (e.g. 'Inline').
+newtype PartialFilterM m p =
+  PartialFilterM
+    { -- | Apply a monadic filter to @p@.
+      applyFilterM :: p -> m p
+    }
+
+-- | @PartialFilter p@ is a wrapper for ordinary @p -> p@ Pandoc filters acting
+-- on a subnode (e.g. 'Inline' or 'Block') of the 'Pandoc' abstract syntax
+-- tree.
+--
+-- * @p@: the type of a subnode of 'Pandoc' (e.g. 'Inline').
+type PartialFilter = PartialFilterM Identity
+
+-- | An alias for @PartialFilter Pandoc@. It encapsulates a monadic
+-- filter @'Pandoc' -> m 'Pandoc'@ acting directly on 'Pandoc'.
+--
+-- * @m@: a monad.
+type PandocFilter = PartialFilter Pandoc
+
+-- | An alias for @PartialFilterM m Pandoc@, a monadic version of
+-- 'PandocFilter'.
+--
+-- * @m@: a monad.
+type PandocFilterM m = PartialFilterM m Pandoc
+
+-- | Apply an ordinary filter to @p@, which returns @p@ directly.
+applyFilter
+  :: PartialFilter p -- ^ A wrapped partial filter.
+  -> p               -- ^ 'Pandoc' AST node.
+  -> p               -- ^ Transformed node.
+applyFilter = (runIdentity .) . applyFilterM
+
+-- | It is mostly the same as 'applyFilterM' and should be used when you don't
+-- need to apply the filter immediately. The only difference is that it will
+-- perform an implicit conversion if the requested filter function is of a
+-- different type.
+getFilterM
+  :: (Monad m, Walkable a b)
+  => PartialFilterM m a -- ^ A wrapped partial filter on @a@.
+  -> (b -> m b)         -- ^ Unwrapped filter function that can be directly applied to @b@.
+getFilterM = applyFilterM . mkFilter
+
+-- | It is mostly the same as 'applyFilter' and should be used when you don't
+-- need to apply the filter immediately. The only difference is that it will
+-- perform an implicit conversion if the requested filter function is of a
+-- different type.
+getFilter
+  :: (Walkable a b)
+  => PartialFilter a -- ^ A wrapped partial filter on @a@.
+  -> (b -> b)        -- ^ Unwrapped filter function that can be directly applied to @b@.
+getFilter = applyFilter . mkFilter
+
+-- | The 'Semigroup' instance of `PartialFilterM`. @f1 <> f2@ will apply @f1@
+-- first followed by @f2@.
+instance (Monad m) => Semigroup (PartialFilterM m p) where
+  f1 <> f2 = PartialFilterM (applyFilterM f1 >=> applyFilterM f2)
+
+-- | The 'Monoid' instance of `PartialFilterM`.
+instance (Monad m) => Monoid (PartialFilterM m p) where
+  mempty = PartialFilterM return
+
+-- | A helper typeclass used as a polymorphic constructor of 'PartialFilterM'.
+class ToPartialFilter m f p where
+  -- | The actual constructor of 'PartialFilterM'. It takes an ordinary filter
+  -- function @a -> b@ and wraps it as a 'PartialFilterM'. It can also be used
+  -- to convert between different types of @'PartialFilterM' m@.
+  mkFilter
+    :: f                  -- ^ A partial filter function, usually @a -> a@ for some @'Walkable' a p@.
+    -> PartialFilterM m p -- ^ Wrapped partial Pandoc filter.
+
+instance (Monad m, Walkable a p) => ToPartialFilter m (a -> a) p where
+  mkFilter = PartialFilterM . (return .) . walk
+
+instance (Monad m, Walkable a p) => ToPartialFilter m (a -> m a) p where
+  mkFilter = PartialFilterM . walkM
+
+instance (Monad m, Walkable [a] p) => ToPartialFilter m (a -> [a]) p where
+  mkFilter = PartialFilterM . (return .) . walk . concatMap
+
+instance (Monad m, Walkable [a] p) => ToPartialFilter m (a -> m [a]) p where
+  mkFilter = PartialFilterM . walkM . (fmap concat .) . mapM
+
+-- | This instance can be used to convert @'PartialFilterM' m a@ to
+-- @'PartialFilterM' m b@.
+instance (Monad m, Walkable a b) => ToPartialFilter m (PartialFilterM m a) b where
+  mkFilter = mkFilter . applyFilterM
+
+-- | Construct a 'PartialFilterM' from a list of filter functions of the same
+-- type. The final filter is concatenated from left to right such that the
+-- first element in the list will be applied first and the last element will be
+-- applied at the end.
+mkConcatedFilter
+  :: (Monad m, ToPartialFilter m f p, Foldable t)
+  => t f                -- ^ A list of filter functions of the same type.
+  -> PartialFilterM m p -- ^ Concatenated filter.
+mkConcatedFilter = foldMap mkFilter
+
+-- | Convert an ordinary 'PartialFilter' to the monadic version
+-- 'PartialFilterM'.
+toFilterM
+  :: (Monad m)
+  => PartialFilter p    -- ^ An ordinary filter.
+  -> PartialFilterM m p -- ^ The monadic version.
+toFilterM = PartialFilterM . (return .) . applyFilter
+
+-- | Apply a list of monadic partial filters sequentially, from left to
+-- right, i.e.  the first element in the list will be applied first and the
+-- last element will be applied at the end.
+applyFiltersM
+  :: (Foldable t, Monad m)
+  => t (PartialFilterM m p) -- ^ A list of monadic partial filters.
+  -> p                      -- ^ 'Pandoc' AST node.
+  -> m p                    -- ^ Transformed node.
+applyFiltersM = applyFilterM . fold
+
+-- | Apply a list of partial filters sequentially, from left to right, i.e.
+-- the first element in the list will be applied first and the last element
+-- will be applied at the end.
+applyFilters
+  :: (Foldable t)
+  => t (PartialFilter p) -- ^ A list of partial filter.
+  -> p                   -- ^ 'Pandoc' AST node.
+  -> p                   -- ^ Transformed node.
+applyFilters = applyFilter . fold
+
+-- | An alias for 'applyFiltersM', used when the filter is not used
+-- immediately.
+getConcatedFilterM
+  :: (Foldable t, Monad m)
+  => t (PartialFilterM m p) -- ^ A list of monadic partial filters.
+  -> (p -> m p)             -- ^ Unwrapped monadic filter applicable to @p@ directly.
+getConcatedFilterM = applyFiltersM
+
+-- | An alias for 'applyFilters', used when the filter is not used immediately.
+getConcatedFilter
+  :: (Foldable t)
+  => t (PartialFilter p) -- ^ A list of partial filter.
+  -> (p -> p)            -- ^ Unwrapped filter applicable to @p@ directly.
+getConcatedFilter = applyFilters
diff --git a/src/Text/Pandoc/Filter/Utils/AttrBuilder.hs b/src/Text/Pandoc/Filter/Utils/AttrBuilder.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Filter/Utils/AttrBuilder.hs
@@ -0,0 +1,52 @@
+-- | This module contains some utility functions for building
+-- attributes.
+module Text.Pandoc.Filter.Utils.AttrBuilder (
+  -- * Attribute builders
+  addClass,
+  addClasses,
+  addKVPair,
+  addKVPairs,
+  setId,
+) where
+
+import Text.Pandoc.Utils
+
+import Data.Bifunctor         (bimap, first, second)
+import Data.Text              (Text)
+import Text.Pandoc.Definition
+
+-- | Append a new class to attributes.
+addClass
+  :: Attr -- ^ Original attributes.
+  -> Text -- ^ Name of the class.
+  -> Attr -- ^ New attributes.
+attr `addClass` cls = first (fromText cls:) attr
+  -- (i, fromText newCls:cls, p)
+
+-- | Append a list of classes to attributes.
+addClasses
+  :: Attr   -- ^ Original attributes.
+  -> [Text] -- ^ A list of class names.
+  -> Attr   -- ^ New attributes.
+attr `addClasses` clses = first (map fromText clses<>) attr
+
+-- | Append a new key-value pair to attributes.
+addKVPair
+  :: Attr         -- ^ Original attributes.
+  -> (Text, Text) -- ^ A key-value pair.
+  -> Attr         -- ^ New attributes.
+attr `addKVPair` p = second (bimap fromText fromText p:) attr
+
+-- | Append new key-value pairs to attributes.
+addKVPairs
+  :: Attr           -- ^ Original attributes.
+  -> [(Text, Text)] -- ^ A list of key-value pairs.
+  -> Attr           -- ^ New attributes.
+attr `addKVPairs` ps = second (map (bimap fromText fromText) ps <>) attr
+
+-- | Set the id of attributes.
+setId
+  :: Attr -- ^ Original attributes.
+  -> Text -- ^ The id of the attrbute.
+  -> Attr -- ^ New attributes.
+(_, cls, p) `setId` i = (i, cls, p)
diff --git a/src/Text/Pandoc/Utils.hs b/src/Text/Pandoc/Utils.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Utils.hs
@@ -0,0 +1,52 @@
+{-# LANGUAGE TypeFamilies #-}
+
+-- | Due to the switch from 'String' to 'Text' in pandoc-types 1.20, some
+-- legacy filters might not work in newer versions. This module contains some
+-- string conversion utility functions to make filters work in across different
+-- versions.
+module Text.Pandoc.Utils (
+  -- * Conversions
+  ToString (..),
+  ToText (..),
+  IsText (..),
+  -- * Reexport from Data.String
+  IsString (..),
+  ) where
+
+import qualified Data.Text as T
+
+import Data.String (IsString (..))
+import Data.Text   (Text)
+
+-- | A helper typeclass for converting 'String' and 'Text' to 'String'.
+class IsString s => ToString s where
+  -- | Convert strings to 'String'.
+  toString :: s -> String
+
+instance Char ~ c => ToString [c] where
+  toString = id
+
+instance ToString Text where
+  toString = T.unpack
+
+-- | A helper typeclass for converting 'String' and 'Text' to 'Text'.
+class IsString s => ToText s where
+  -- | Convert strings to 'Text'.
+  toText :: s -> Text
+
+instance Char ~ c => ToText [c] where
+  toText = T.pack
+
+instance ToText Text where
+  toText = id
+
+-- | A helper typeclass for converting 'Text' to either 'String' or 'Text'.
+class IsText a where
+  -- | Convert from 'Text' to strings
+  fromText :: Text -> a
+
+instance Char ~ c => IsText [c] where
+  fromText = T.unpack
+
+instance IsText Text where
+  fromText = id
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,364 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main where
+
+import qualified Data.Text   as T
+import qualified Text.Pandoc as P
+
+import Control.Monad.Trans.Writer
+import Data.Default                         (def)
+import Data.Either                          (fromRight)
+import Data.Text                            (Text)
+import Test.Tasty
+import Test.Tasty.Hspec
+import Text.Pandoc.Definition
+import Text.Pandoc.Filter.Utils
+import Text.Pandoc.Filter.Utils.AttrBuilder
+
+-- * Testing data
+
+-- ** Partial level
+
+blockPara :: Block
+blockPara = Para [Str "abcd"]
+
+expectedInlinePartial :: Block
+expectedInlinePartial = Para [Str "ABCD"]
+
+expectedInlinePartialL :: Block
+expectedInlinePartialL = Para [Str "abcd", Str "abcd"]
+
+-- ** Pandoc level
+
+docPara :: Pandoc
+docPara = Pandoc (Meta mempty) [blockPara]
+
+expectedInline :: Pandoc
+expectedInline = Pandoc (Meta mempty) [Para [Str "ABCD"]]
+
+expectedBlock :: Pandoc
+expectedBlock = Pandoc (Meta mempty) [Plain [Str "abcd"]]
+
+expectedInlineL :: Pandoc
+expectedInlineL = Pandoc (Meta mempty) [Para [Str "abcd", Str "abcd"]]
+
+expectedBlockL :: Pandoc
+expectedBlockL = Pandoc (Meta mempty) [blockPara, blockPara]
+
+-- ** Composition results
+
+compPara :: Block
+compPara = Para [Str "abcd"]
+
+compPara2 :: Block
+compPara2 = Para [Str "abcd", Str "efgh"]
+
+expectedDup :: Block
+expectedDup = Para [Str "abcd", Str "abcd"]
+
+expectedMerge :: Block
+expectedMerge = Para [Str "abcdefgh"]
+
+expectedDupMerge :: Block
+expectedDupMerge = Para [Str "abcdabcd"]
+
+expectedMergeDup :: Block
+expectedMergeDup = Para [Str "abcd", Str "abcd"]
+
+-- * Filters
+
+-- ** Plain filters
+
+capFilterInline :: Inline -> Inline
+capFilterInline (Str str) = Str $ T.toUpper str
+capFilterInline x         = x
+
+uncapFilterInline :: Inline -> Inline
+uncapFilterInline (Str str) = Str $ T.toLower str
+uncapFilterInline x         = x
+
+unParaFilterBlock :: Block -> Block
+unParaFilterBlock (Para ils) = Plain ils
+unParaFilterBlock x          = x
+
+dupFilterInline :: Inline -> [Inline]
+dupFilterInline (Str str) = [Str str, Str str]
+dupFilterInline x         = [x]
+
+mergeFilterInline :: [Inline] -> [Inline]
+mergeFilterInline (Str str1 : Str str2 : xs) = Str (str1 <> str2) : xs
+mergeFilterInline x                          = x
+
+dupFilterBlock :: Block -> [Block]
+dupFilterBlock (Para ils) = [Para ils, Para ils]
+dupFilterBlock x          = [x]
+
+-- ** Monadic filters
+
+extractFilterInlineM :: Inline -> Writer Text Inline
+extractFilterInlineM il@(Str str) = tell str >> return (capFilterInline il)
+extractFilterInlineM x            = return x
+
+extractFilterInlineML :: Inline -> Writer Text [Inline]
+extractFilterInlineML il@(Str str) = tell str >> return (dupFilterInline il)
+extractFilterInlineML x            = return [x]
+
+extractFilter :: Inline -> Writer Text Inline
+extractFilter il@(Str str) = tell str >> return il
+extractFilter x            = return x
+
+-- * Readme example
+
+-- ** Filters
+
+behead :: Block -> Block
+behead (Header n _ xs) | n >= 2 = Para [Emph xs]
+behead x               = x
+
+beheadFilter :: PandocFilter
+beheadFilter = mkFilter behead
+
+delink :: Inline -> [Inline]
+delink (Link _ txt _) = txt
+delink x              = [x]
+
+delinkFilter :: PandocFilter
+delinkFilter = mkFilter delink
+
+myFilter :: PandocFilter
+myFilter = beheadFilter <> delinkFilter
+
+-- ** Documents
+
+readmeText :: Text
+readmeText = T.strip $ T.unlines
+  [ "## Heading"
+  , "Hello, [Pandoc](https://pandoc.org)."
+  ]
+
+readmeDoc :: Pandoc
+readmeDoc = Pandoc (Meta mempty)
+  [ Header 2 ("heading", [], []) [Str "Heading"]
+  , Para [ Str "Hello,"
+         , Space
+         , Link ("",[],[]) [Str "Pandoc"] ("https://pandoc.org","")
+         , Str "."
+         ]
+  ]
+
+expectedHtml :: Text
+expectedHtml = T.strip $ T.unlines
+  [ "<p><em>Heading</em></p>"
+  , "<p>Hello, Pandoc.</p>"
+  ]
+
+expectedDoc :: Pandoc
+expectedDoc = Pandoc (Meta mempty)
+  [ Para [Emph [Str "Heading"]]
+  , Para [ Str "Hello,"
+         , Space
+         , Str "Pandoc"
+         , Str "."
+         ]
+  ]
+
+-- ** The example
+
+mdToHtml
+  :: Text                      -- ^ Input markdown string
+  -> Either P.PandocError Text -- ^ Html string or error
+mdToHtml md = P.runPure $ do
+  doc <- P.readMarkdown def md
+  let doc' = applyFilters [beheadFilter, delinkFilter] doc
+  P.writeHtml5String def doc'
+
+mdToHtmlCompose
+  :: Text                      -- ^ Input markdown string
+  -> Either P.PandocError Text -- ^ Html string or error
+mdToHtmlCompose md = P.runPure $ do
+  doc <- P.readMarkdown def md
+  let doc' = applyFilter myFilter doc
+  P.writeHtml5String def doc'
+
+
+convertSpec :: Spec
+convertSpec = parallel $ do
+  describe "mkFilter" $ do
+    it "converts a -> a filter to Pandoc -> Pandoc filter" $ do
+      applyFilter (mkFilter capFilterInline) docPara `shouldBe` expectedInline
+      applyFilter (mkFilter unParaFilterBlock) docPara `shouldBe` expectedBlock
+
+    it "converts a -> a filter to b -> b partial filter" $
+      applyFilter (mkFilter capFilterInline) blockPara `shouldBe` expectedInlinePartial
+
+    it "converts a -> [a] filter to Pandoc -> Pandoc filter" $ do
+      applyFilter (mkFilter dupFilterInline) docPara `shouldBe` expectedInlineL
+      applyFilter (mkFilter dupFilterBlock) docPara `shouldBe` expectedBlockL
+
+    it "converts a -> [a] filter to b -> b partial filter" $
+      applyFilter (mkFilter dupFilterInline) blockPara `shouldBe` expectedInlinePartialL
+
+    it "converts a -> m a filter to Pandoc -> m Pandoc filter" $ do
+      let (doc, s) = runWriter $ applyFilterM (mkFilter extractFilterInlineM) docPara
+      doc `shouldBe` expectedInline
+      s `shouldBe` T.pack "abcd"
+
+    it "converts a -> m a filter to b -> m b filter" $ do
+      let (bl, s) = runWriter $ applyFilterM (mkFilter extractFilterInlineM) blockPara
+      bl `shouldBe` expectedInlinePartial
+      s `shouldBe` T.pack "abcd"
+
+    it "converts a -> m [a] filter to Pandoc -> m Pandoc filter" $ do
+      let (doc, s) = runWriter $ applyFilterM (mkFilter extractFilterInlineML) docPara
+      doc `shouldBe` expectedInlineL
+      s `shouldBe` T.pack "abcd"
+
+    it "converts a -> m [a] filter to b -> m b filter" $ do
+      let (bl, s) = runWriter $ applyFilterM (mkFilter extractFilterInlineML) blockPara
+      bl `shouldBe` expectedInlinePartialL
+      s `shouldBe` T.pack "abcd"
+
+    it "converts PartialFilter a to PartialFilter b" $ do
+      let fInline = mkFilter capFilterInline :: PartialFilter Inline
+          fBlock = mkFilter fInline          :: PartialFilter Block
+          fPandoc = mkFilter fInline         :: PandocFilter
+      applyFilter fBlock blockPara `shouldBe` expectedInlinePartial
+      applyFilter fPandoc docPara `shouldBe` expectedInline
+
+    it "converts PartialFilterM m a to PartialFilterM m b" $ do
+      let fInline :: PartialFilterM (Writer Text) Inline
+          fInline = mkFilter extractFilterInlineM
+          fBlock = mkFilter fInline  :: PartialFilterM (Writer Text) Block
+          fPandoc = mkFilter fInline :: PandocFilterM (Writer Text)
+      let (doc, s) = runWriter $ applyFilterM fPandoc docPara
+      doc `shouldBe` expectedInline
+      s `shouldBe` T.pack "abcd"
+
+      let (bl, s') = runWriter $ applyFilterM fBlock blockPara
+      bl `shouldBe` expectedInlinePartial
+      s' `shouldBe` T.pack "abcd"
+
+  describe "getFilter" $ do
+    it "converts PartialFilter a to a -> a" $ do
+      let fInline = mkFilter capFilterInline :: PartialFilter Inline
+      getFilter fInline (Str "abcd") `shouldBe` Str "ABCD"
+
+    it "converts PartialFilterM m a to a -> m a" $ do
+      let fInline :: PartialFilterM (Writer Text) Inline
+          fInline = mkFilter extractFilterInlineM
+      let (doc, s) = runWriter $ getFilterM fInline (Str "abcd")
+      doc `shouldBe` Str "ABCD"
+      s `shouldBe` T.pack "abcd"
+
+    it "converts PartialFilter a to b -> b" $ do
+      let fInline = mkFilter capFilterInline :: PartialFilter Inline
+          fBlock = getFilter fInline         :: Block -> Block
+          fPandoc = getFilter fInline        :: Pandoc -> Pandoc
+      fBlock blockPara `shouldBe` expectedInlinePartial
+      fPandoc docPara `shouldBe` expectedInline
+
+    it "converts PartialFilterM m a to b -> m b" $ do
+      let fInline :: PartialFilterM (Writer Text) Inline
+          fInline = mkFilter extractFilterInlineM
+          fBlock  = getFilterM fInline :: Block  -> Writer Text Block
+          fPandoc = getFilterM fInline :: Pandoc -> Writer Text Pandoc
+      let (doc, s) = runWriter $ fPandoc docPara
+      doc `shouldBe` expectedInline
+      s `shouldBe` T.pack "abcd"
+
+      let (bl, s') = runWriter $ fBlock blockPara
+      bl `shouldBe` expectedInlinePartial
+      s' `shouldBe` T.pack "abcd"
+
+  describe "mkConcatedFilter" $ do
+    let cap = mkConcatedFilter [uncapFilterInline, capFilterInline]
+        uncap = mkConcatedFilter [capFilterInline, uncapFilterInline]
+    it "concats filter from left to right" $ do
+      applyFilter cap docPara `shouldBe` expectedInline
+      applyFilter uncap docPara `shouldBe` docPara
+
+  describe "toFilterM" $
+    it "converts a -> a filter to a -> m a filter" $ do
+      let capFilterM = toFilterM $ mkFilter capFilterInline
+      applyFilterM capFilterM (Str "abcd") `shouldBe` Just (Str "ABCD")
+
+composeSpec :: Spec
+composeSpec = parallel $ do
+  let dup = mkFilter dupFilterInline
+      merge = mkFilter mergeFilterInline
+      extract = mkFilter extractFilter
+  describe "applyFilter" $ do
+    it "applys dup correctly" $
+      applyFilter dup compPara `shouldBe` expectedDup
+
+    it "applys merge correctly" $
+      applyFilter merge compPara2 `shouldBe` expectedMerge
+
+  describe "applyFilters and monoid" $ do
+    it "applys PartialFilter composition from left to right" $ do
+      applyFilter (dup <> merge) compPara `shouldBe` expectedDupMerge
+      applyFilter (merge <> dup) compPara `shouldBe` expectedMergeDup
+      applyFilters [dup, merge] compPara `shouldBe` expectedDupMerge
+      applyFilters [merge, dup] compPara `shouldBe` expectedMergeDup
+
+    it "applys PartialFilterM composition from left to right" $ do
+      let (doc, s) = runWriter $ applyFilterM (extract <> toFilterM dup) compPara
+      doc `shouldBe` expectedDup
+      s `shouldBe` T.pack "abcd"
+
+      let (doc', s') = runWriter $ applyFilterM (toFilterM dup <> extract) compPara
+      doc' `shouldBe` expectedDup
+      s' `shouldBe` T.pack "abcdabcd"
+
+readmeSpec :: Spec
+readmeSpec = parallel $
+  describe "readme example" $ do
+    it "processes filter examples correctly on AST level" $ do
+      applyFilters [beheadFilter, delinkFilter] readmeDoc `shouldBe` expectedDoc
+      applyFilter myFilter readmeDoc `shouldBe` expectedDoc
+
+    it "processes filter examples correctly on Text level" $ do
+      fromRight "" (mdToHtml readmeText) `shouldBe` expectedHtml
+      fromRight "" (mdToHtmlCompose readmeText) `shouldBe` expectedHtml
+
+attrBuilderSpec :: Spec
+attrBuilderSpec = parallel $ do
+  let testAttr = ("id", ["test"], [("k", "v")])
+  describe "addClass" $
+    it "adds a new class to attributes" $ do
+      nullAttr `addClass` "test" `shouldBe` ("", ["test"], [])
+      nullAttr `addClass` "test" `addClass` "test2" `shouldBe` ("", ["test2", "test"], [])
+      testAttr `addClass` "test2" `shouldBe` ("id", ["test2", "test"], [("k", "v")])
+
+  describe "addClasses" $
+    it "adds new classes to attributes" $ do
+      nullAttr `addClasses` ["test", "test2"] `shouldBe` ("", ["test", "test2"], [])
+      testAttr `addClasses` ["test1", "test2"] `shouldBe` ("id", ["test1", "test2", "test"], [("k", "v")])
+
+  describe "addKVPair" $
+    it "adds a new kv pair to attributes" $ do
+      nullAttr `addKVPair` ("k", "v") `shouldBe` ("", [], [("k", "v")])
+      testAttr `addKVPair` ("k2", "v2") `shouldBe` ("id", ["test"], [("k2", "v2"), ("k", "v")])
+
+  describe "addKVPairs" $
+    it "adds new kv pairs to attributes" $ do
+      nullAttr `addKVPairs` [("k", "v"), ("k2", "v2")] `shouldBe` ("", [], [("k", "v"), ("k2", "v2")])
+      testAttr `addKVPairs` [("k1", "v1"), ("k2", "v2")] `shouldBe` ("id", ["test"], [("k1", "v1"), ("k2", "v2"), ("k", "v")])
+
+  describe "setId" $
+    it "sets id of attributes" $ do
+      nullAttr `setId` "id" `shouldBe` ("id", [], [])
+      testAttr `setId` "id2" `shouldBe` ("id2", ["test"], [("k", "v")])
+
+main :: IO ()
+main = do
+  testConvert <- testSpec "Filter conversion" convertSpec
+  testCompose <- testSpec "Filter composition" composeSpec
+  testReadme <- testSpec "Readme examples" readmeSpec
+  testAttrBuilder <- testSpec "Attr builder" attrBuilderSpec
+  defaultMain $ testGroup "Tests"
+    [ testConvert
+    , testCompose
+    , testReadme
+    , testAttrBuilder
+    ]
