diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,14 @@
+# Changelog
+
+* **1.0.1.0**
+  * Loosen version constraints on dependencies. Most importantly, allow all 4.x
+    versions of `base`.
+* **1.0.0.0**:
+  * Report errors
+  * Add support for dedenting
+  * Remove undocumented "formatted" feature
+  * Separate library and executable
+* Before 1.0.0.0:
+  * **2017-04-19:** Bump version
+  * **2017-04-18:** Only render as RawBlock when preformatted
+  * **2017-02-21:** Use new Pandoc 1.19
diff --git a/filter/Main.hs b/filter/Main.hs
new file mode 100644
--- /dev/null
+++ b/filter/Main.hs
@@ -0,0 +1,21 @@
+{-# LANGUAGE LambdaCase #-}
+module Main where
+
+import System.Environment
+
+import           Text.Pandoc.JSON
+import           Text.Pandoc.Filter.IncludeCode
+
+import Paths_pandoc_include_code
+import qualified Data.Version          as Version
+
+main :: IO ()
+main =
+  getArgs >>=
+    \case
+      (arg:_)
+        | arg == "-V" -> showVersion
+        | arg == "--version" -> showVersion
+      _ -> toJSONFilter includeCode
+  where
+    showVersion = putStrLn (Version.showVersion version)
diff --git a/pandoc-include-code.cabal b/pandoc-include-code.cabal
--- a/pandoc-include-code.cabal
+++ b/pandoc-include-code.cabal
@@ -1,30 +1,49 @@
-name:          pandoc-include-code
-synopsis:      A Pandoc filter for including code from source files
+name:                pandoc-include-code
+synopsis:            A Pandoc filter for including code from source files
 description:
   A Pandoc filter for including code from source files.
   It lets you keep your examples and documentation compiled and in sync,
   include small snippets from larger source files, and use Markdown or LaTeX
   together with preformatted HTML-like sources, in Pandoc.
-author:        Oskar Wickström
-maintainer:    Oskar Wickström
-homepage:	     https://github.com/owickstrom/pandoc-include-code
-version:       0.3.0
-cabal-version: >= 1.8
-build-type:    Simple
-category:      Documentation
-license:       MPL-2.0
-license-file:  LICENSE
+author:              Oskar Wickström
+maintainer:          Oskar Wickström
+homepage:	           https://github.com/owickstrom/pandoc-include-code
+version:             1.0.1.0
+cabal-version:       >= 1.8
+build-type:          Simple
+category:            Documentation
+license:             MPL-2.0
+license-file:        LICENSE
+extra-source-files:  CHANGELOG.md
 
 source-repository head
   type:     git
   location: git://github.com/owickstrom/pandoc-include-code.git
 
-executable pandoc-include-code
+library
     hs-source-dirs:  src
-    main-is:         Main.hs
-    build-depends:   base   >= 4      && < 5
-                   , containers
+    exposed-modules: Text.Pandoc.Filter.IncludeCode
+    build-depends:   base                 >= 4        && < 5
+                   , unordered-containers >= 0.2      && < 0.3
                    , process
                    , filepath
-                   , pcre-heavy
-                   , pandoc-types
+                   , mtl                  >= 2.2      && < 3
+                   , pandoc-types         >= 1.12     && < 1.18
+
+
+executable pandoc-include-code
+    hs-source-dirs:  filter
+    main-is:         Main.hs
+    build-depends:   base                 >= 4        && < 5
+                   , pandoc-types         >= 1.12     && < 1.18
+                   , pandoc-include-code
+
+test-suite filter-tests
+    type:            exitcode-stdio-1.0
+    hs-source-dirs:  test
+    main-is:         Driver.hs
+    build-depends:   base                 >= 4        && < 5
+                   , pandoc-types         >= 1.12     && < 1.18
+                   , pandoc-include-code
+                   , tasty
+                   , tasty-hunit
diff --git a/src/Main.hs b/src/Main.hs
deleted file mode 100644
--- a/src/Main.hs
+++ /dev/null
@@ -1,120 +0,0 @@
-{-# LANGUAGE FlexibleContexts #-}
-{-# LANGUAGE QuasiQuotes      #-}
-
-module Main where
-
-import qualified Data.Map              as Map
-
-import           Data.Function         ((&))
-import           Data.List             (isInfixOf)
-import           Text.Pandoc.JSON
-import           Text.Read
-import           Text.Regex.PCRE.Heavy
-
-type IsEscaped = Bool
-
-encloseInListingEscape :: IsEscaped -> String -> String
-encloseInListingEscape True s  = s
-encloseInListingEscape False s = "@" ++ s ++ "@"
-
-escapeForLatex :: IsEscaped -> String -> String
-escapeForLatex isEscaped = concatMap escape
-  where
-    escape '$' =
-      if isEscaped
-        then "\\$"
-        else "$"
-    escape c = [c]
-
-replaceDashWithLatex :: IsEscaped -> String -> String
-replaceDashWithLatex isEscaped = gsub ([re|(\--)|]) toLatex
-  where
-    toLatex ("--":_) = encloseInListingEscape isEscaped "-{}-"
-    toLatex _        = ""
-
-replaceTagWithLatex :: IsEscaped -> String -> String
-replaceTagWithLatex isEscaped s = foldl replaceWith s replacements
-  where
-    replacements =
-      [ ([re|<strong>(.*?)</strong>|], "\\texttt{\\textbf{", "}}")
-      , ([re|<em>(.*?)</em>|], "\\texttt{\\textit{", "}}")
-      , ([re|<sub>(.*?)</sub>|], "\\textsubscript{", "}")
-      ]
-    replaceWith s' (r, pre, post) = gsub r (toLatex pre post) s'
-    toLatex pre post (contents:_) =
-      let replacedContents = replaceWithLatex True contents
-          command = pre ++ replacedContents ++ post
-      in encloseInListingEscape isEscaped command
-    toLatex _ _ [] = ""
-
-replaceWithLatex :: Bool -> String -> String
-replaceWithLatex isEscaped =
-  replaceDashWithLatex isEscaped .
-  replaceTagWithLatex isEscaped . escapeForLatex isEscaped
-
-postProcess :: Format -> String -> String
-postProcess fmt contents
-  | fmt == Format "latex" =
-    unlines $ map (replaceWithLatex False) $ lines contents
-  | otherwise = contents
-
-getRange :: Map.Map String String -> Maybe (Int, Int)
-getRange attrs = do
-  start <- Map.lookup "startLine" attrs >>= readMaybe
-  end <- Map.lookup "endLine" attrs >>= readMaybe
-  if start <= end
-    then return (start, end)
-    else Nothing
-
-withinLines :: Maybe (Int, Int) -> String -> String
-withinLines range =
-  case range of
-    Just (start, end) ->
-      unlines . take (end - startIndex) . drop startIndex . lines
-      where startIndex = pred start
-    Nothing -> id
-
-onlySnippet :: Map.Map String String -> String -> String
-onlySnippet attrs =
-  case Map.lookup "snippet" attrs of
-    Just name ->
-      unlines .
-      takeWhile (not . isSnippetEnd) .
-      drop 1 . dropWhile (not . isSnippetStart) . lines
-      where isSnippetTag tag line =
-              (tag ++ " snippet " ++ name) `isInfixOf` line
-            isSnippetStart = isSnippetTag "start"
-            isSnippetEnd = isSnippetTag "end"
-    Nothing -> id
-
-includeCode :: Maybe Format -> Block -> IO Block
-includeCode (Just fmt) cb@(CodeBlock (id', classes, attrs) _) = do
-  let attrs' = Map.fromList attrs
-  case Map.lookup "include" attrs' of
-    Just f -> do
-      let isFormatted = "formatted" `Map.member` attrs'
-      fileContents <-
-        if isFormatted
-          then postProcess fmt <$> readFile f
-          else readFile f
-      let filteredLines =
-            fileContents & withinLines (getRange attrs') & onlySnippet attrs'
-          paramNames =
-            ["include", "formatted", "startLine", "endLine", "snippet"]
-          filteredAttrs = foldl (flip Map.delete) attrs' paramNames
-          classes' = unwords classes
-      case fmt of
-        Format "html5" | isFormatted ->
-          return
-            (RawBlock
-               (Format "html")
-               ("<pre class=" ++
-                classes' ++ "><code>" ++ filteredLines ++ "</code></pre>"))
-        _ ->
-          return
-            (CodeBlock (id', classes, Map.toList filteredAttrs) filteredLines)
-    Nothing -> return cb
-includeCode _ x = return x
-
-main :: IO ()
-main = toJSONFilter includeCode
diff --git a/src/Text/Pandoc/Filter/IncludeCode.hs b/src/Text/Pandoc/Filter/IncludeCode.hs
new file mode 100644
--- /dev/null
+++ b/src/Text/Pandoc/Filter/IncludeCode.hs
@@ -0,0 +1,168 @@
+{-# LANGUAGE CPP                        #-}
+{-# LANGUAGE FlexibleContexts           #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE LambdaCase                 #-}
+{-# LANGUAGE NamedFieldPuns             #-}
+{-# LANGUAGE RecordWildCards            #-}
+
+module Text.Pandoc.Filter.IncludeCode
+  ( includeCode
+  ) where
+#if MIN_VERSION_base(4,8,0)
+#else
+import           Control.Applicative
+import           Data.Monoid
+#endif
+import           Control.Exception
+import           Control.Monad.Except
+import           Control.Monad.Reader
+import           Data.Char            (isSpace)
+import           Data.HashMap.Strict  (HashMap)
+import qualified Data.HashMap.Strict  as HM
+import           Data.List            (isInfixOf)
+import           Data.Maybe           (fromMaybe)
+import           Text.Pandoc.JSON
+import           Text.Read            (readMaybe)
+
+data Range = Range
+  { startLine :: Int
+  , endLine   :: Int
+  }
+
+mkRange :: Int -> Int -> Maybe Range
+mkRange s e
+  | s > 0 && e > 0 && s <= e = Just (Range s e)
+  | otherwise = Nothing
+
+data InclusionSpec = InclusionSpec
+  { include :: FilePath
+  , snippet :: Maybe String
+  , range   :: Maybe Range
+  , dedent  :: Maybe Int
+  }
+
+data MissingRangePart
+  = Start
+  | End
+  deriving (Show, Eq)
+
+data InclusionError
+  = InvalidRange Int
+                 Int
+  | IncompleteRange MissingRangePart
+  deriving (Show, Eq)
+
+newtype Inclusion a = Inclusion
+  { runInclusion :: ReaderT InclusionSpec (ExceptT InclusionError IO) a
+  } deriving ( Functor
+             , Applicative
+             , Monad
+             , MonadIO
+             , MonadReader InclusionSpec
+             , MonadError InclusionError
+             )
+
+runInclusion' :: InclusionSpec -> Inclusion a -> IO (Either InclusionError a)
+runInclusion' spec action = runExceptT (runReaderT (runInclusion action) spec)
+
+parseInclusion ::
+     HashMap String String -> Either InclusionError (Maybe InclusionSpec)
+parseInclusion attrs =
+  case HM.lookup "include" attrs of
+    Just include -> do
+      range <- getRange
+      return (Just InclusionSpec {..})
+    Nothing -> return Nothing
+  where
+    lookupInt name = HM.lookup name attrs >>= readMaybe
+    snippet = HM.lookup "snippet" attrs
+    dedent = lookupInt "dedent"
+    getRange =
+      case (lookupInt "startLine", lookupInt "endLine") of
+        (Just start, Just end) ->
+          maybe
+            (throwError (InvalidRange start end))
+            (return . Just)
+            (mkRange start end)
+        (Nothing, Just _) -> throwError (IncompleteRange Start)
+        (Just _, Nothing) -> throwError (IncompleteRange End)
+        (Nothing, Nothing) -> return Nothing
+
+type Lines = [String]
+
+readIncluded :: Inclusion String
+readIncluded = liftIO . readFile =<< asks include
+
+filterLineRange :: Lines -> Inclusion Lines
+filterLineRange ls =
+  asks range >>= \case
+    Just (Range start end) ->
+      return (take (end - startIndex) (drop startIndex ls))
+      where startIndex = pred start
+    Nothing -> return ls
+
+onlySnippet :: Lines -> Inclusion Lines
+onlySnippet ls = do
+  s <- asks snippet
+  case s of
+    Just name ->
+      return $
+      drop 1 $
+      takeWhile (not . isSnippetEnd) $ dropWhile (not . isSnippetStart) ls
+      where isSnippetTag tag line =
+              (tag ++ " snippet " ++ name) `isInfixOf` line
+            isSnippetStart = isSnippetTag "start"
+            isSnippetEnd = isSnippetTag "end"
+    Nothing -> return ls
+
+dedentLines :: Lines -> Inclusion Lines
+dedentLines ls = do
+  d <- asks dedent
+  case d of
+    Just n  -> return (map (dedentLine n) ls)
+    Nothing -> return ls
+  where
+    dedentLine 0 line = line
+    dedentLine _ "" = ""
+    dedentLine n (c:cs)
+      | isSpace c = dedentLine (pred n) cs
+      | otherwise = c : cs
+
+filterAttributes :: [(String, String)] -> [(String, String)]
+filterAttributes = filter nonFilterAttribute
+  where
+    nonFilterAttribute (key, _) = key `notElem` attributeNames
+    attributeNames = ["include", "startLine", "endLine", "snippet", "dedent"]
+
+printAndFail :: InclusionError -> IO Block
+printAndFail = fail . formatError
+  where
+    formatError =
+      \case
+        InvalidRange start end ->
+          "Invalid range: " ++ show start ++ " to " ++ show end
+        IncompleteRange Start -> "Incomplete range: \"startLine\" is missing"
+        IncompleteRange End -> "Incomplete range: \"endLine\" is missing"
+
+splitLines :: String -> Inclusion Lines
+splitLines = return . lines
+
+joinLines :: Lines -> Inclusion String
+joinLines = return . unlines
+
+-- | A Pandoc filter that includes code snippets from external files.
+includeCode :: Maybe Format -> Block -> IO Block
+includeCode _ cb@(CodeBlock (id', classes, attrs) _) =
+  case parseInclusion (HM.fromList attrs) of
+    Right (Just spec) ->
+      runInclusion'
+        spec
+        (readIncluded >>= splitLines >>= filterLineRange >>= onlySnippet >>=
+         dedentLines >>=
+         joinLines) >>= \case
+        Left err -> printAndFail err
+        Right contents ->
+          return (CodeBlock (id', classes, filterAttributes attrs) contents)
+    Right Nothing -> return cb
+    Left err -> printAndFail err
+includeCode _ x = return x
diff --git a/test/Driver.hs b/test/Driver.hs
new file mode 100644
--- /dev/null
+++ b/test/Driver.hs
@@ -0,0 +1,9 @@
+module Main where
+
+import Test.Tasty
+import Test.Tasty.HUnit
+
+import qualified FilterTest
+
+main :: IO ()
+main = defaultMain FilterTest.tests
