diff --git a/Data/Warc.hs b/Data/Warc.hs
deleted file mode 100644
--- a/Data/Warc.hs
+++ /dev/null
@@ -1,114 +0,0 @@
-{-# LANGUAGE RankNTypes #-}
-{-# LANGUAGE ScopedTypeVariables #-}
-{-# LANGUAGE GADTs #-}
-
--- | WARC (or Web ARCive) is a archival file format widely used to distribute
--- corpora of crawled web content (see, for instance the Common Crawl corpus). A
--- WARC file consists of a set of records, each of which describes a web request
--- or response.
---
--- This module provides a streaming parser and encoder for WARC archives for use
--- with the @pipes@ package.
---
-module Data.Warc
-    ( Warc(..)
-    , Record(..)
-      -- * Parsing
-    , parseWarc
-    , iterRecords
-    , produceRecords
-      -- * Encoding
-    , encodeRecord
-      -- * Headers
-    , module Data.Warc.Header
-    ) where
-
-import Data.Char (ord)
-import Pipes hiding (each)
-import qualified Pipes.ByteString as PBS
-import Control.Lens
-import qualified Pipes.Attoparsec as PA
-import qualified Data.ByteString as BS
-import qualified Data.ByteString.Lazy.Builder as BB
-import Data.ByteString (ByteString)
-import Control.Monad (join)
-import Control.Monad.Trans.Free
-import Control.Monad.Trans.State.Strict
-
-import Data.Warc.Header
-
-
--- | A WARC record
---
--- This represents a single record of a WARC file, consisting of a set of
--- headers and a means of producing the record's body.
-data Record m r = Record { recHeader    :: RecordHeader
-                           -- ^ the WARC headers
-                         , recContent   :: Producer BS.ByteString m r
-                           -- ^ the body of the record
-                         }
-
-instance Monad m => Functor (Record m) where
-    fmap f (Record hdr r) = Record hdr (fmap f r)
-
--- | A WARC archive.
---
--- This represents a sequence of records followed by whatever data
--- was leftover from the parse.
-type Warc m a = FreeT (Record m) m (Producer BS.ByteString m a)
-
--- | Parse a WARC archive.
---
--- Note that this function does not actually do any parsing itself;
--- it merely returns a 'Warc' value which can then be run to parse
--- individual records.
-parseWarc :: (Functor m, Monad m)
-          => Producer ByteString m a   -- ^ a producer of a stream of WARC content
-          -> Warc m a                  -- ^ the parsed WARC archive
-parseWarc = loop
-  where
-    loop upstream = FreeT $ do
-        (hdr, rest) <- runStateT (PA.parse header) upstream
-        go hdr rest
-
-    go mhdr rest
-      | Nothing <- mhdr             = return $ Pure rest
-      | Just (Left err) <- mhdr     = error $ show err
-      | Just (Right hdr) <- mhdr
-      , Just len <- hdr ^? recHeaders . each . _ContentLength = do
-            let produceBody = fmap consumeWhitespace . view (PBS.splitAt len)
-                consumeWhitespace = PBS.dropWhile isEOL
-                isEOL c = c == ord8 '\r' || c == ord8 '\n'
-                ord8 = fromIntegral . ord
-            return $ Free $ Record hdr $ fmap loop $ produceBody rest
-
--- | Iterate over the 'Record's in a WARC archive
-iterRecords :: forall m a. Monad m
-            => (forall b. Record m b -> m b)  -- ^ the action to run on each 'Record'
-            -> Warc m a                       -- ^ the 'Warc' file
-            -> m (Producer BS.ByteString m a) -- ^ returns any leftover data
-iterRecords f warc = iterT iter warc
-  where
-    iter :: Record m (m (Producer BS.ByteString m a))
-         -> m (Producer BS.ByteString m a)
-    iter r = join $ f r
-
-produceRecords :: forall m o a. Monad m
-               => (forall b. RecordHeader -> Producer BS.ByteString m b
-                                          -> Producer o m b)
-                  -- ^ consume the record producing some output
-               -> Warc m a
-                  -- ^ a WARC archive (see 'parseWarc')
-               -> Producer o m (Producer BS.ByteString m a)
-                  -- ^ returns any leftover data
-produceRecords f warc = iterTM iter warc
-  where
-    iter :: Record m (Producer o m (Producer BS.ByteString m a))
-         -> Producer o m (Producer BS.ByteString m a)
-    iter (Record hdr body) = join $ f hdr body
-
--- | Encode a 'Record' in WARC format.
-encodeRecord :: Monad m => Record m a -> Producer BS.ByteString m a
-encodeRecord (Record hdr content) = do
-    PBS.fromLazy $ BB.toLazyByteString $ encodeHeader hdr
-    content
diff --git a/Data/Warc/Header.hs b/Data/Warc/Header.hs
deleted file mode 100644
--- a/Data/Warc/Header.hs
+++ /dev/null
@@ -1,331 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE TemplateHaskell #-}
-
-module Data.Warc.Header
-    ( -- * Parsing
-      header
-      -- * Encoding
-    , encodeHeader
-      -- * Types
-    , RecordHeader(..)
-    , Version(..)
-    , WarcType(..)
-    , RecordId(..)
-    , TruncationReason(..)
-    , Digest(..)
-    , Uri(..)
-      -- * Header field types
-    , Field(..)
-      -- ** Prisms
-    , _WarcRecordId
-    , _ContentLength
-    , _WarcDate
-    , _WarcType
-    , _ContentType
-    , _WarcConcurrentTo
-    , _WarcBlockDigest
-    , _WarcPayloadDigest
-    , _WarcIpAddress
-    , _WarcRefersTo
-    , _WarcTargetUri
-    , _WarcTruncated
-    , _WarcWarcinfoId
-    , _WarcFilename
-    , _WarcProfile
-    , _WarcIdentifiedPayloadType
-    , _WarcSegmentNumber
-    , _WarcSegmentOriginId
-    , _WarcSegmentTotalLength
-      -- * Lenses
-    , recWarcVersion, recHeaders
-    ) where
-
-import Control.Applicative
-import Control.Monad (void)
-import Data.Maybe (catMaybes)
-import Data.Monoid ((<>))
-import Data.Time.Clock
-import Data.Time.Format
-import Data.Char (ord)
-
-import Data.Attoparsec.ByteString.Char8
-import Data.Text (Text)
-import qualified Data.Text as T
-import qualified Data.Text.Encoding as TE
-import qualified Data.Text.Lazy as TL
-import Data.ByteString.Char8 (ByteString)
-import qualified Data.ByteString.Char8 as BS
-import qualified Data.ByteString.Lazy.Builder as BB
-
-import Control.Lens
-
-withName :: String -> Parser a -> Parser a
-withName name parser = parser <?> name
-
-data Version = Version {versionMajor, versionMinor :: !Int}
-             deriving (Show, Read, Eq, Ord)
-
-version :: Parser Version
-version = withName "version" $ do
-    "WARC/"
-    major <- decimal
-    char '.'
-    minor <- decimal
-    return (Version major minor)
-
-newtype FieldName = FieldName {getFieldName :: Text}
-                  deriving (Show, Read)
-
-instance Eq FieldName where
-    FieldName a == FieldName b = T.toCaseFold a == T.toCaseFold b
-
-instance Ord FieldName where
-    FieldName a `compare` FieldName b = T.toCaseFold a `compare` T.toCaseFold b
-
-separators :: String
-separators = "()<>@,;:\\\"/[]?={}"
-
-crlf :: Parser ()
-crlf = void $ string "\r\n"
-
-token :: Parser ByteString
-token = takeTill (inClass $ separators++" \t\n\r")
-
-utf8Token :: Parser Text
-utf8Token = TE.decodeUtf8 <$> token
-
-fieldName :: Parser FieldName
-fieldName = FieldName . TE.decodeUtf8 <$> token
-
-ord' = fromIntegral . ord
-
-text :: Parser Text
-text = do
-    let content :: TL.Text -> Parser TL.Text
-        content accum = do
-            satisfy (isHorizontalSpace . ord')
-            c <- takeTill (isEndOfLine . ord')
-            continuation (accum <> TL.fromStrict (TE.decodeUtf8 c))
-        continuation :: TL.Text -> Parser TL.Text
-        continuation accum = content accum <|> return accum
-    firstLine <- takeTill (isEndOfLine . ord')
-    TL.toStrict <$> continuation (TL.fromStrict $ TE.decodeUtf8 firstLine)
-
-quotedString :: Parser Text
-quotedString = do
-    char '"'
-    c <- TE.decodeUtf8 <$> takeTill (== '"')
-    char '"'
-    return c
-
-field :: Parser name -> Parser a -> Parser a
-field name content = do
-    try name
-    char ':'
-    skipSpace
-    content <* endOfLine
-
-data WarcType = WarcInfo
-              | Response
-              | Resource
-              | Request
-              | Metadata
-              | Revisit
-              | Conversion
-              | Continuation
-              | FutureType !Text
-              deriving (Show, Read, Ord, Eq)
-
-warcType :: Parser WarcType
-warcType = choice
-     [ "warcinfo"     *> pure WarcInfo
-     , "response"     *> pure Response
-     , "resource"     *> pure Resource
-     , "request"      *> pure Request
-     , "metadata"     *> pure Metadata
-     , "revisit"      *> pure Revisit
-     , "conversion"   *> pure Conversion
-     , "continuation" *> pure Continuation
-     , FutureType <$> utf8Token
-     ]
-
-encodeText :: T.Text -> BB.Builder
-encodeText = BB.byteString . TE.encodeUtf8
-
-encodeWarcType :: WarcType -> BB.Builder
-encodeWarcType WarcInfo       = "warcinfo"
-encodeWarcType Response       = "response"
-encodeWarcType Resource       = "resource"
-encodeWarcType Request        = "request"
-encodeWarcType Metadata       = "metadata"
-encodeWarcType Revisit        = "revisit"
-encodeWarcType Conversion     = "conversion"
-encodeWarcType Continuation   = "continuation"
-encodeWarcType (FutureType t) = encodeText t
-
-newtype Uri = Uri ByteString
-            deriving (Show, Read, Eq, Ord)
-
-uri :: Parser Uri
-uri = do
-    char '<'
-    s <- takeTill (== '>')
-    char '>'
-    return $ Uri s
-
-laxUri :: Parser Uri
-laxUri = Uri <$> takeTill (isEndOfLine . ord')
-
-encodeUri :: Uri -> BB.Builder
-encodeUri (Uri b) = BB.char7 '<' <> BB.byteString b <> BB.char7 '>'
-
-newtype RecordId = RecordId Uri
-                 deriving (Show, Read, Eq, Ord)
-
-recordId :: Parser RecordId
-recordId = RecordId <$> uri
-
-encodeRecordId :: RecordId -> BB.Builder
-encodeRecordId (RecordId r) = encodeUri r
-
-data TruncationReason = TruncLength
-                      | TruncTime
-                      | TruncDisconnect
-                      | TruncUnspecified
-                      | TruncOther !Text
-                      deriving (Show, Read, Ord, Eq)
-
-truncationReason :: Parser TruncationReason
-truncationReason = choice
-    [ "length" *> pure TruncLength
-    , "time"   *> pure TruncTime
-    , "disconnect" *> pure TruncDisconnect
-    , "unspecified" *> pure TruncUnspecified
-    , TruncOther <$> utf8Token
-    ]
-
-encodeTruncationReason :: TruncationReason -> BB.Builder
-encodeTruncationReason TruncLength      = "length"
-encodeTruncationReason TruncTime        = "time"
-encodeTruncationReason TruncDisconnect  = "disconnect"
-encodeTruncationReason TruncUnspecified = "unspecified"
-encodeTruncationReason (TruncOther o)   = encodeText o
-
-data Digest = Digest { digestAlgorithm, digestHash :: !ByteString }
-            deriving (Show, Read, Eq, Ord)
-
-digest :: Parser Digest
-digest = do
-    algo <- token <* char ':'
-    hash <- token
-    return $ Digest algo hash
-
-encodeDigest :: Digest -> BB.Builder
-encodeDigest (Digest algo hash) =
-    BB.byteString algo <> ":" <> BB.byteString hash
-
-data Field = WarcRecordId !RecordId
-           | ContentLength !Integer
-           | WarcDate !UTCTime
-           | WarcType !WarcType
-           | ContentType !ByteString
-           | WarcConcurrentTo !RecordId
-           | WarcBlockDigest !Digest
-           | WarcPayloadDigest !Digest
-           | WarcIpAddress !ByteString
-           | WarcRefersTo !Uri
-           | WarcTargetUri !Uri
-           | WarcTruncated !TruncationReason
-           | WarcWarcinfoId !RecordId
-           | WarcFilename !Text
-           | WarcProfile !Uri
-           | WarcIdentifiedPayloadType !ByteString
-           | WarcSegmentNumber !Integer
-           | WarcSegmentOriginId !ByteString
-           | WarcSegmentTotalLength !Integer
-           deriving (Show, Read)
-
-makePrisms ''Field
-
-date :: Parser UTCTime
-date = do
-    s <- takeTill isSpace
-    parseTimeM False defaultTimeLocale dateFormat (BS.unpack s)
-
-encodeDate :: UTCTime -> BB.Builder
-encodeDate = BB.string7 . formatTime defaultTimeLocale dateFormat
-
-dateFormat = iso8601DateFormat (Just "%H:%M:%SZ")
-
-warcField :: Parser Field
-warcField = choice
-    [ field "WARC-Record-ID" (WarcRecordId <$> recordId)
-    , field "Content-Length" (ContentLength <$> decimal)
-    , field "WARC-Date" (WarcDate <$> date)
-    , field "WARC-Type" (WarcType <$> warcType)
-    , field "Content-Type" (ContentType <$> takeTill (isEndOfLine . ord'))
-    , field "WARC-Concurrent-To" (WarcConcurrentTo <$> recordId)
-    , field "WARC-Block-Digest" (WarcBlockDigest <$> digest)
-    , field "WARC-Payload-Digest" (WarcPayloadDigest <$> digest)
-    , field "WARC-IP-Address" (WarcIpAddress <$> takeTill (isEndOfLine . ord'))
-    , field "WARC-Refers-To" (WarcRefersTo <$> uri)
-    , field "WARC-Target-URI" (WarcTargetUri <$> laxUri)
-    , field "WARC-Truncated" (WarcTruncated <$> truncationReason)
-    , field "WARC-Warcinfo-ID" (WarcWarcinfoId <$> recordId)
-    , field "WARC-Filename" (WarcFilename <$> (text <|> quotedString))
-    , field "WARC-Profile" (WarcProfile <$> uri)
-    -- , field "WARC-Identified-Payload-Type" (WarcIdentifiedPayloadType <$> mediaType)
-    , field "WARC-Segment-Number" (WarcSegmentNumber <$> decimal)
-    --, field "WARC-Segment-Origin-ID" (WarcSegmentOriginId <$> msgId)
-    , field "WARC-Segment-Total-Length" (WarcSegmentTotalLength <$> decimal)
-    ]
-
-data RecordHeader = RecordHeader { _recWarcVersion :: Version
-                                 , _recHeaders     :: [Field]
-                                 }
-                  deriving (Show)
-
-makeLenses ''RecordHeader
-
--- | A WARC header
-header :: Parser RecordHeader
-header = withName "header" $ do
-    skipSpace
-    ver <- version <* endOfLine
-    let unknownField = field token (takeTill (isEndOfLine . ord') *> return Nothing)
-    fields <- withName "fields" $ many $ (Just <$> warcField) <|> unknownField
-    endOfLine
-    return $ RecordHeader ver (catMaybes fields)
-
-encodeHeader :: RecordHeader -> BB.Builder
-encodeHeader (RecordHeader (Version maj min) flds) =
-       "WARC/"<>BB.intDec maj<>"."<>BB.intDec min <> "\n"
-    <> foldMap encodeField flds
-    <> BB.char7 '\n'
-
-encodeField :: Field -> BB.Builder
-encodeField fld =
-    case fld of
-        WarcRecordId r                -> field "WARC-Record-ID" (encodeRecordId r)
-        ContentLength len             -> field "Content-Length" (BB.integerDec len)
-        WarcDate t                    -> field "WARC-Date" (encodeDate t)
-        WarcType t                    -> field "WARC-Type" (encodeWarcType t)
-        ContentType t                 -> field "Content-Type" (BB.byteString t)
-        WarcConcurrentTo r            -> field "WARC-Concurrent-To" (encodeRecordId r)
-        WarcBlockDigest d             -> field "WARC-Block-Digest" (encodeDigest d)
-        WarcPayloadDigest d           -> field "WARC-Payload-Digest" (encodeDigest d)
-        WarcIpAddress addr            -> field "WARC-IP-Address" (BB.byteString addr)
-        WarcRefersTo uri              -> field "WARC-Refers-To" (encodeUri uri)
-        WarcTargetUri uri             -> field "WARC-Target-URI" (encodeUri uri)
-        WarcTruncated t               -> field "WARC-Truncated" (encodeTruncationReason t)
-        WarcWarcinfoId r              -> field "WARC-Warcinfo-ID" (encodeRecordId r)
-        WarcFilename n                -> field "WARC-Filename" (quoted $ encodeText n)
-        WarcProfile uri               -> field "WARC-Profile" (encodeUri uri)
-        WarcSegmentNumber n           -> field "WARC-Segment-Number" (BB.integerDec n)
-        WarcSegmentTotalLength len    -> field "WARC-Segment-Total-Length" (BB.integerDec len)
-  where
-    field :: BB.Builder -> BB.Builder -> BB.Builder
-    field name val = name <> ": " <> val <> BB.char7 '\n'
-
-    quoted x = q <> x <> q
-      where q = BB.char7 '"'
diff --git a/WarcExport.hs b/WarcExport.hs
--- a/WarcExport.hs
+++ b/WarcExport.hs
@@ -8,7 +8,7 @@
 
 import Data.Attoparsec.ByteString.Char8
 import qualified Data.Text as T
-import qualified Data.ByteString as BS
+import qualified Data.ByteString.Char8 as BS
 import Control.Monad.IO.Class
 import Control.Monad.Catch
 
@@ -39,9 +39,11 @@
 outFile :: Record m a -> Maybe FilePath
 outFile r = fileName <|> recId
   where
-    fileName = recHeader r ^? recHeaders . each . _WarcFilename . _Text
-    recId = recHeader r ^? recHeaders . each . _WarcRecordId . to recIdToFileName
-    recIdToFileName (RecordId (Uri uri)) = "hello"
+    fileName = r ^? to recHeader . field warcFilename . _Text
+    recId = r ^? to recHeader . field warcRecordId . to recIdToFileName
+    recIdToFileName (RecordId (Uri uri)) = map escape $ BS.unpack uri
+      where escape '/' = '-'
+            escape c   = c
 
 doExport :: FilePath -> FilePath -> IO ()
 doExport outDir warcPath = do
diff --git a/src/Data/Warc.hs b/src/Data/Warc.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Warc.hs
@@ -0,0 +1,114 @@
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE GADTs #-}
+
+-- | WARC (or Web ARCive) is a archival file format widely used to distribute
+-- corpora of crawled web content (see, for instance the Common Crawl corpus). A
+-- WARC file consists of a set of records, each of which describes a web request
+-- or response.
+--
+-- This module provides a streaming parser and encoder for WARC archives for use
+-- with the @pipes@ package.
+--
+module Data.Warc
+    ( Warc(..)
+    , Record(..)
+      -- * Parsing
+    , parseWarc
+    , iterRecords
+    , produceRecords
+      -- * Encoding
+    , encodeRecord
+      -- * Headers
+    , module Data.Warc.Header
+    ) where
+
+import Data.Char (ord)
+import Pipes hiding (each)
+import qualified Pipes.ByteString as PBS
+import Control.Lens
+import qualified Pipes.Attoparsec as PA
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Lazy.Builder as BB
+import Data.ByteString (ByteString)
+import Control.Monad (join)
+import Control.Monad.Trans.Free
+import Control.Monad.Trans.State.Strict
+
+import Data.Warc.Header
+
+
+-- | A WARC record
+--
+-- This represents a single record of a WARC file, consisting of a set of
+-- headers and a means of producing the record's body.
+data Record m r = Record { recHeader    :: RecordHeader
+                           -- ^ the WARC headers
+                         , recContent   :: Producer BS.ByteString m r
+                           -- ^ the body of the record
+                         }
+
+instance Monad m => Functor (Record m) where
+    fmap f (Record hdr r) = Record hdr (fmap f r)
+
+-- | A WARC archive.
+--
+-- This represents a sequence of records followed by whatever data
+-- was leftover from the parse.
+type Warc m a = FreeT (Record m) m (Producer BS.ByteString m a)
+
+-- | Parse a WARC archive.
+--
+-- Note that this function does not actually do any parsing itself;
+-- it merely returns a 'Warc' value which can then be run to parse
+-- individual records.
+parseWarc :: (Functor m, Monad m)
+          => Producer ByteString m a   -- ^ a producer of a stream of WARC content
+          -> Warc m a                  -- ^ the parsed WARC archive
+parseWarc = loop
+  where
+    loop upstream = FreeT $ do
+        (hdr, rest) <- runStateT (PA.parse header) upstream
+        go hdr rest
+
+    go mhdr rest
+      | Nothing <- mhdr             = return $ Pure rest
+      | Just (Left err) <- mhdr     = error $ show err
+      | Just (Right hdr) <- mhdr
+      , Just (Right len) <- lookupField hdr contentLength = do
+            let produceBody = fmap consumeWhitespace . view (PBS.splitAt len)
+                consumeWhitespace = PBS.dropWhile isEOL
+                isEOL c = c == ord8 '\r' || c == ord8 '\n'
+                ord8 = fromIntegral . ord
+            return $ Free $ Record hdr $ fmap loop $ produceBody rest
+
+-- | Iterate over the 'Record's in a WARC archive
+iterRecords :: forall m a. Monad m
+            => (forall b. Record m b -> m b)  -- ^ the action to run on each 'Record'
+            -> Warc m a                       -- ^ the 'Warc' file
+            -> m (Producer BS.ByteString m a) -- ^ returns any leftover data
+iterRecords f warc = iterT iter warc
+  where
+    iter :: Record m (m (Producer BS.ByteString m a))
+         -> m (Producer BS.ByteString m a)
+    iter r = join $ f r
+
+produceRecords :: forall m o a. Monad m
+               => (forall b. RecordHeader -> Producer BS.ByteString m b
+                                          -> Producer o m b)
+                  -- ^ consume the record producing some output
+               -> Warc m a
+                  -- ^ a WARC archive (see 'parseWarc')
+               -> Producer o m (Producer BS.ByteString m a)
+                  -- ^ returns any leftover data
+produceRecords f warc = iterTM iter warc
+  where
+    iter :: Record m (Producer o m (Producer BS.ByteString m a))
+         -> Producer o m (Producer BS.ByteString m a)
+    iter (Record hdr body) = join $ f hdr body
+
+-- | Encode a 'Record' in WARC format.
+encodeRecord :: Monad m => Record m a -> Producer BS.ByteString m a
+encodeRecord (Record hdr content) = do
+    PBS.fromLazy $ BB.toLazyByteString $ encodeHeader hdr
+    content
diff --git a/src/Data/Warc/Header.hs b/src/Data/Warc/Header.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Warc/Header.hs
@@ -0,0 +1,391 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE RankNTypes #-}
+
+module Data.Warc.Header
+    ( -- * Parsing
+      header
+      -- * Encoding
+    , encodeHeader
+      -- * WARC Version
+    , Version(..)
+    , warc0_16
+      -- * Types
+    , RecordHeader(..)
+    , WarcType(..)
+    , RecordId(..)
+    , TruncationReason(..)
+    , Digest(..)
+    , Uri(..)
+      -- * Header field types
+    , Field(..)
+    , FieldName(..)
+    , field
+    , lookupField
+    , addField
+    , mapField
+    , rawField
+      -- ** Standard fields
+    , warcRecordId
+    , contentLength
+    , warcDate
+    , warcType
+    , contentType
+    , warcConcurrentTo
+    , warcBlockDigest
+    , warcPayloadDigest
+    , warcIpAddress
+    , warcRefersTo
+    , warcTargetUri
+    , warcTruncated
+    , warcWarcinfoID
+    , warcFilename
+    , warcProfile
+    , warcSegmentNumber
+    , warcSegmentTotalLength
+      -- * Lenses
+    , recWarcVersion, recHeaders
+    ) where
+
+import Control.Applicative
+import Control.Monad (void, guard)
+import Data.Maybe (catMaybes)
+import Data.Monoid ((<>))
+import Data.Time.Clock
+import Data.Time.Format
+import Data.Char (ord)
+import Data.String (IsString)
+
+import Data.Attoparsec.ByteString.Char8 as A
+import qualified Data.Attoparsec.ByteString.Lazy as AL
+import qualified Data.HashMap.Strict as HM
+import Data.Hashable (Hashable(..))
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as TE
+import qualified Data.Text.Lazy as TL
+import Data.ByteString.Char8 (ByteString)
+import qualified Data.ByteString.Char8 as BS
+import qualified Data.ByteString.Lazy as BSL
+import qualified Data.ByteString.Lazy.Builder as BB
+
+import Control.Lens
+
+withName :: String -> Parser a -> Parser a
+withName name parser = parser <?> name
+
+data Version = Version {versionMajor, versionMinor :: !Int}
+             deriving (Show, Read, Eq, Ord)
+
+warc0_16 :: Version
+warc0_16 = Version 0 16
+
+version :: Parser Version
+version = withName "version" $ do
+    "WARC/"
+    major <- decimal
+    char '.'
+    minor <- decimal
+    return (Version major minor)
+
+newtype FieldName = FieldName {getFieldName :: Text}
+                  deriving (Show, Read, IsString)
+
+instance Hashable FieldName where
+    hashWithSalt salt (FieldName t) = hashWithSalt salt (T.toCaseFold t)
+
+instance Eq FieldName where
+    FieldName a == FieldName b = T.toCaseFold a == T.toCaseFold b
+
+instance Ord FieldName where
+    FieldName a `compare` FieldName b = T.toCaseFold a `compare` T.toCaseFold b
+
+separators :: String
+separators = "()<>@,;:\\\"/[]?={}"
+
+crlf :: Parser ()
+crlf = void $ string "\r\n"
+
+token :: Parser ByteString
+token = takeTill (inClass $ separators++" \t\n\r")
+
+utf8Token :: Parser Text
+utf8Token = TE.decodeUtf8 <$> token
+
+ord' = fromIntegral . ord
+
+text :: Parser Text
+text = do
+    let content :: TL.Text -> Parser TL.Text
+        content accum = do
+            satisfy (isHorizontalSpace . ord')
+            c <- takeTill (isEndOfLine . ord')
+            endOfLine
+            continuation (accum <> TL.fromStrict (TE.decodeUtf8 c))
+        continuation :: TL.Text -> Parser TL.Text
+        continuation accum = content accum <|> return accum
+    firstLine <- takeTill (isEndOfLine . ord')
+    endOfLine
+    TL.toStrict <$> continuation (TL.fromStrict $ TE.decodeUtf8 firstLine)
+
+quotedString :: Parser Text
+quotedString = do
+    char '"'
+    c <- TE.decodeUtf8 <$> takeTill (== '"')
+    char '"'
+    return c
+
+data WarcType = WarcInfo
+              | Response
+              | Resource
+              | Request
+              | Metadata
+              | Revisit
+              | Conversion
+              | Continuation
+              | FutureType !Text
+              deriving (Show, Read, Ord, Eq)
+
+parseWarcType :: Parser WarcType
+parseWarcType = choice
+     [ "warcinfo"     *> pure WarcInfo
+     , "response"     *> pure Response
+     , "resource"     *> pure Resource
+     , "request"      *> pure Request
+     , "metadata"     *> pure Metadata
+     , "revisit"      *> pure Revisit
+     , "conversion"   *> pure Conversion
+     , "continuation" *> pure Continuation
+     , FutureType <$> utf8Token
+     ]
+
+encodeText :: T.Text -> BB.Builder
+encodeText = BB.byteString . TE.encodeUtf8
+
+encodeWarcType :: WarcType -> BB.Builder
+encodeWarcType WarcInfo       = "warcinfo"
+encodeWarcType Response       = "response"
+encodeWarcType Resource       = "resource"
+encodeWarcType Request        = "request"
+encodeWarcType Metadata       = "metadata"
+encodeWarcType Revisit        = "revisit"
+encodeWarcType Conversion     = "conversion"
+encodeWarcType Continuation   = "continuation"
+encodeWarcType (FutureType t) = encodeText t
+
+newtype Uri = Uri ByteString
+            deriving (Show, Read, Eq, Ord)
+
+uri :: Parser Uri
+uri = do
+    char '<'
+    s <- takeTill (== '>')
+    char '>'
+    return $ Uri s
+
+laxUri :: Parser Uri
+laxUri = Uri <$> takeTill (isEndOfLine . ord')
+
+encodeUri :: Uri -> BB.Builder
+encodeUri (Uri b) = BB.char7 '<' <> BB.byteString b <> BB.char7 '>'
+
+newtype RecordId = RecordId Uri
+                 deriving (Show, Read, Eq, Ord)
+
+recordId :: Parser RecordId
+recordId = RecordId <$> uri
+
+encodeRecordId :: RecordId -> BB.Builder
+encodeRecordId (RecordId r) = encodeUri r
+
+data TruncationReason = TruncLength
+                      | TruncTime
+                      | TruncDisconnect
+                      | TruncUnspecified
+                      | TruncOther !Text
+                      deriving (Show, Read, Ord, Eq)
+
+truncationReason :: Parser TruncationReason
+truncationReason = choice
+    [ "length" *> pure TruncLength
+    , "time"   *> pure TruncTime
+    , "disconnect" *> pure TruncDisconnect
+    , "unspecified" *> pure TruncUnspecified
+    , TruncOther <$> utf8Token
+    ]
+
+encodeTruncationReason :: TruncationReason -> BB.Builder
+encodeTruncationReason TruncLength      = "length"
+encodeTruncationReason TruncTime        = "time"
+encodeTruncationReason TruncDisconnect  = "disconnect"
+encodeTruncationReason TruncUnspecified = "unspecified"
+encodeTruncationReason (TruncOther o)   = encodeText o
+
+data Digest = Digest { digestAlgorithm, digestHash :: !ByteString }
+            deriving (Show, Read, Eq, Ord)
+
+digest :: Parser Digest
+digest = do
+    algo <- token <* char ':'
+    hash <- token
+    return $ Digest algo hash
+
+encodeDigest :: Digest -> BB.Builder
+encodeDigest (Digest algo hash) =
+    BB.byteString algo <> ":" <> BB.byteString hash
+
+date :: Parser UTCTime
+date = do
+    s <- takeTill isSpace
+    parseTimeM False defaultTimeLocale dateFormat (BS.unpack s)
+
+encodeDate :: UTCTime -> BB.Builder
+encodeDate = BB.string7 . formatTime defaultTimeLocale dateFormat
+
+dateFormat = iso8601DateFormat (Just "%H:%M:%SZ")
+
+warcField :: Parser (FieldName, BSL.ByteString)
+warcField = withName "field" $ do
+    peekChar' >>= guard . not . isSpace
+    fieldName <- FieldName . TE.decodeUtf8 <$> A.takeTill (== ':')
+    char ':'
+    skipSpace
+    v0 <- takeLine
+    endOfLine
+    let continuation :: BB.Builder -> Parser BB.Builder
+        continuation v = do
+            c <- peekChar
+            case c of
+              Just c' | isHorizontalSpace (fromIntegral $ ord c') -> do
+                            v' <- takeLine
+                            endOfLine
+                            continuation (v <> BB.byteString v')
+              _ -> return v
+    v1 <- continuation (BB.byteString v0)
+    return (fieldName, BB.toLazyByteString v1)
+
+-- | Take the rest of the line (but leaving the newline character unparsed).
+takeLine :: Parser BS.ByteString
+takeLine = A.takeTill (isEndOfLine . ord')
+
+data RecordHeader = RecordHeader { _recWarcVersion :: Version
+                                 , _recHeaders     :: HM.HashMap FieldName BSL.ByteString
+                                 }
+                  deriving (Show)
+
+makeLenses ''RecordHeader
+
+-- | A lens-y means of querying 'Field's.
+field :: Field a -> Traversal' RecordHeader a
+field fld = recHeaders . ix (fieldName fld) . parsedField fld
+
+parsedField :: Field a -> Prism' BSL.ByteString a
+parsedField fld = prism' to from
+  where
+    from bs = case AL.parse (decode fld) bs of
+                AL.Fail _ _ _ -> Nothing
+                AL.Done _ x   -> Just x
+    to = BB.toLazyByteString . encode fld
+
+addField :: Field a -> a -> RecordHeader -> RecordHeader
+addField fld v =
+    recHeaders . at (fieldName fld) .~ Just (BB.toLazyByteString $ encode fld v)
+
+-- | A WARC header
+header :: Parser RecordHeader
+header = withName "header" $ do
+    skipSpace
+    ver <- version <* endOfLine
+    fields <- fmap HM.fromList <$> withName "fields" $ many $ warcField
+    endOfLine
+    return $ RecordHeader ver fields
+
+encodeHeader :: RecordHeader -> BB.Builder
+encodeHeader (RecordHeader (Version maj min) flds) =
+       "WARC/"<>BB.intDec maj<>"."<>BB.intDec min <> "\r\n"
+    <> foldMap field (HM.toList flds)
+    <> "\r\n"
+  where field :: (FieldName, BSL.ByteString) -> BB.Builder
+        field (FieldName fname, value) =
+            TE.encodeUtf8Builder fname <> ": " <> BB.lazyByteString value <> "\r\n"
+
+-- | Lookup the value of a field. Returns @Nothing@ if the field is not
+-- present, @Just (Left err)@ in the event of a parse error, and
+-- @Just (Right v)@ on success.
+lookupField :: RecordHeader -> Field a -> Maybe (Either String a)
+lookupField (RecordHeader {_recHeaders=headers}) fld
+  | Just v <- HM.lookup (fieldName fld) headers
+  = case AL.parse (decode fld) v of
+      AL.Fail _ _ err -> Just $ Left err
+      AL.Done _ x     -> Just $ Right x
+  | otherwise
+  = Nothing
+
+data Field a = Field { fieldName :: FieldName
+                     , encode    :: a -> BB.Builder
+                     , decode    :: Parser a
+                     }
+
+mapField :: (a -> b) -> (b -> a) -> Field a -> Field b
+mapField f g (Field fieldName encode decode) =
+    Field fieldName (encode . g) (f <$> decode)
+
+warcRecordId :: Field RecordId
+warcRecordId = Field "WARC-Record-ID" encodeRecordId recordId
+
+contentLength :: Field Integer
+contentLength = Field "Content-Length" BB.integerDec decimal
+
+warcDate :: Field UTCTime
+warcDate = Field "WARC-Date" encodeDate date
+
+warcType :: Field WarcType
+warcType = Field "WARC-Type" encodeWarcType parseWarcType
+
+contentType :: Field BS.ByteString
+contentType = Field "Content-Type" BB.byteString (takeTill (isEndOfLine . ord'))
+
+warcConcurrentTo :: Field RecordId
+warcConcurrentTo = Field "WARC-Concurrent-To" encodeRecordId recordId
+
+warcBlockDigest :: Field Digest
+warcBlockDigest = Field "WARC-Block-Digest" encodeDigest digest
+
+warcPayloadDigest :: Field Digest
+warcPayloadDigest = Field "WARC-Payload-Digest" encodeDigest digest
+
+warcIpAddress :: Field BS.ByteString
+warcIpAddress = Field "WARC-IP-Address" BB.byteString (takeTill (isEndOfLine . ord'))
+
+warcRefersTo :: Field Uri
+warcRefersTo = Field "WARC-Refers-To" encodeUri uri
+
+warcTargetUri :: Field Uri
+warcTargetUri = Field "WARC-Target-URI" encodeUri laxUri
+
+warcTruncated :: Field TruncationReason
+warcTruncated = Field "WARC-Truncated" encodeTruncationReason truncationReason
+
+warcWarcinfoID :: Field RecordId
+warcWarcinfoID = Field "WARC-Warcinfo-ID" encodeRecordId recordId
+
+warcFilename :: Field T.Text
+warcFilename = Field "WARC-Filename"(quoted . encodeText) (text <|> quotedString)
+
+warcProfile :: Field Uri
+warcProfile = Field "WARC-Profile" encodeUri uri
+
+warcSegmentNumber :: Field Integer
+warcSegmentNumber = Field "WARC-Segment-Number" BB.integerDec decimal
+
+warcSegmentTotalLength :: Field Integer
+warcSegmentTotalLength = Field "WARC-Segment-Total-Length" BB.integerDec decimal
+
+rawField :: FieldName -> Field BSL.ByteString
+rawField fname = Field fname BB.lazyByteString takeLazyByteString
+
+quoted x = q <> x <> q
+  where q = BB.char7 '"'
+
diff --git a/warc.cabal b/warc.cabal
--- a/warc.cabal
+++ b/warc.cabal
@@ -1,5 +1,5 @@
 name:                warc
-version:             0.3.1
+version:             1.0.1
 synopsis:            A parser for the Web Archive (WARC) format
 description:         A streaming parser for the Web Archive (WARC) format.
 homepage:            http://github.com/bgamari/warc
@@ -19,13 +19,16 @@
 library
   exposed-modules:     Data.Warc, Data.Warc.Header
   other-extensions:    RankNTypes, OverloadedStrings, TemplateHaskell
-  build-depends:       base >=4.8 && <4.10,
+  hs-source-dirs:      src
+  build-depends:       base >=4.8 && <4.11,
                        pipes >=4.1 && <4.3,
                        attoparsec >=0.12 && <0.14,
+                       unordered-containers >=0.2 && <0.3,
+                       hashable >=1.2 && <1.3,
                        bytestring >=0.10 && <0.11,
                        pipes-bytestring >=2.1 && <2.2,
                        transformers >=0.4 && <0.6,
-                       lens >=4.7 && <4.15,
+                       lens >=4.7 && <4.16,
                        pipes-attoparsec >=0.5 && <0.6,
                        free >=4.10 && <4.13,
                        errors >=1.4 && <3.0,
