diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,1 @@
+See https://codeberg.org/pbrisbin/mdoc/releases
diff --git a/README.lhs b/README.lhs
new file mode 100644
--- /dev/null
+++ b/README.lhs
@@ -0,0 +1,124 @@
+# network-uri-template
+
+Library for parsing and expanding URI Templates, as per [RFC 6570][rfc6570].
+
+[rfc6570]: https://datatracker.ietf.org/doc/html/rfc6570
+
+## URI Templates
+
+An example from the RFC:
+
+> For example, the following URI Template includes a form-style
+> parameter expression, as indicated by the "?" operator appearing
+> before the variable names.
+>
+> ```
+>   http://www.example.com/foo{?query,number}
+> ```
+>
+> The expansion process for expressions beginning with the question-
+> mark ("?") operator follows the same pattern as form-style interfaces
+> on the World Wide Web:
+>
+> ```
+>   http://www.example.com/foo{?query,number}
+>                             \_____________/
+>                                |
+>                                |
+>           For each defined variable in [ 'query', 'number' ],
+>           substitute "?" if it is the first substitution or "&"
+>           thereafter, followed by the variable name, '=', and the
+>           variable's value.
+> ```
+>
+> If the variables have the values
+>
+> ```
+>   query  := "mycelium"
+>   number := 100
+> ```
+>
+> then the expansion of the above URI Template is
+>
+> ```
+>   http://www.example.com/foo?query=mycelium&number=100
+> ```
+>
+> Alternatively, if 'query' is undefined, then the expansion would be
+>
+> ```
+>   http://www.example.com/foo?number=100
+> ```
+>
+> or if both variables are undefined, then it would be
+>
+> ```
+>   http://www.example.com/foo
+> ```
+
+For a complete description of URI Templates, consult the RFC or see our
+extracted [test cases][examples].
+
+[examples]: rfc/examples.txt
+
+## Example
+
+```haskell
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main
+  ( main
+  ) where
+
+import Prelude
+
+import Data.Text (Text)
+import Data.Text.IO qualified as T
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Network.URI.Template
+import System.Exit (die)
+
+main :: IO ()
+main = do
+  let
+    vars :: Map VarName VarValue
+    vars =
+      Map.fromList
+        [ ("query", "mycelium")
+        , ("number", "100")
+        ]
+
+    template :: Text
+    template = "http://www.example.com/foo{?query,number}"
+
+    handle = either (die . templateErrorPretty) T.putStrLn
+
+  handle $ processTemplate vars template
+  -- => http://www.example.com/foo?query=mycelium&number=100
+
+  handle $ processTemplate (Map.delete "query" vars) template
+  -- => http://www.example.com/foo?number=100
+
+  handle $ processTemplate (Map.empty) template
+  -- => http://www.example.com/foo
+```
+
+## CLI
+
+This project includes a small CLI to experiment with template expansion.
+
+```console
+network-uri-template \
+  --var 'query  := "mycelium"' \
+  --var 'number := "100"' \
+  --var 'path   := ("foo", "bar")' \
+  --var 'keys   := [("sort","asc"), ("page","2")]' \
+  'http://www.example.com{/path*}/foo{?query:2,number}{&keys*}'
+```
+
+![](./cli.png)
+
+## License
+
+This project is licensed AGPLv3. See [COPYING](./COPYING).
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,124 @@
+# network-uri-template
+
+Library for parsing and expanding URI Templates, as per [RFC 6570][rfc6570].
+
+[rfc6570]: https://datatracker.ietf.org/doc/html/rfc6570
+
+## URI Templates
+
+An example from the RFC:
+
+> For example, the following URI Template includes a form-style
+> parameter expression, as indicated by the "?" operator appearing
+> before the variable names.
+>
+> ```
+>   http://www.example.com/foo{?query,number}
+> ```
+>
+> The expansion process for expressions beginning with the question-
+> mark ("?") operator follows the same pattern as form-style interfaces
+> on the World Wide Web:
+>
+> ```
+>   http://www.example.com/foo{?query,number}
+>                             \_____________/
+>                                |
+>                                |
+>           For each defined variable in [ 'query', 'number' ],
+>           substitute "?" if it is the first substitution or "&"
+>           thereafter, followed by the variable name, '=', and the
+>           variable's value.
+> ```
+>
+> If the variables have the values
+>
+> ```
+>   query  := "mycelium"
+>   number := 100
+> ```
+>
+> then the expansion of the above URI Template is
+>
+> ```
+>   http://www.example.com/foo?query=mycelium&number=100
+> ```
+>
+> Alternatively, if 'query' is undefined, then the expansion would be
+>
+> ```
+>   http://www.example.com/foo?number=100
+> ```
+>
+> or if both variables are undefined, then it would be
+>
+> ```
+>   http://www.example.com/foo
+> ```
+
+For a complete description of URI Templates, consult the RFC or see our
+extracted [test cases][examples].
+
+[examples]: rfc/examples.txt
+
+## Example
+
+```haskell
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main
+  ( main
+  ) where
+
+import Prelude
+
+import Data.Text (Text)
+import Data.Text.IO qualified as T
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Network.URI.Template
+import System.Exit (die)
+
+main :: IO ()
+main = do
+  let
+    vars :: Map VarName VarValue
+    vars =
+      Map.fromList
+        [ ("query", "mycelium")
+        , ("number", "100")
+        ]
+
+    template :: Text
+    template = "http://www.example.com/foo{?query,number}"
+
+    handle = either (die . templateErrorPretty) T.putStrLn
+
+  handle $ processTemplate vars template
+  -- => http://www.example.com/foo?query=mycelium&number=100
+
+  handle $ processTemplate (Map.delete "query" vars) template
+  -- => http://www.example.com/foo?number=100
+
+  handle $ processTemplate (Map.empty) template
+  -- => http://www.example.com/foo
+```
+
+## CLI
+
+This project includes a small CLI to experiment with template expansion.
+
+```console
+network-uri-template \
+  --var 'query  := "mycelium"' \
+  --var 'number := "100"' \
+  --var 'path   := ("foo", "bar")' \
+  --var 'keys   := [("sort","asc"), ("page","2")]' \
+  'http://www.example.com{/path*}/foo{?query:2,number}{&keys*}'
+```
+
+![](./cli.png)
+
+## License
+
+This project is licensed AGPLv3. See [COPYING](./COPYING).
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,129 @@
+-- |
+--
+-- Module      : Main
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Main
+  ( main
+  ) where
+
+import Prelude
+
+import Data.Foldable (fold)
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Text.IO qualified as T
+import Network.URI.Template
+import Network.URI.Template.Expand
+import Network.URI.Template.Internal
+import Network.URI.Template.Internal.Parse (parseOpt)
+import Network.URI.Template.Internal.Pretty hiding (Ann)
+import Network.URI.Template.Internal.Pretty qualified as Template
+import Options.Applicative
+import Prettyprinter.Render.Terminal qualified as Ansi
+import Prettyprinter.Render.Text qualified as Text
+import System.IO (hIsTerminalDevice, stdout)
+
+data Options = Options
+  { variables :: Map VarName VarValue
+  , template :: Template
+  }
+
+parseOptions :: IO Options
+parseOptions =
+  execParser
+    $ info (optionsParser <**> helper)
+    $ progDesc "Expand a URI template"
+
+optionsParser :: Parser Options
+optionsParser =
+  Options . fold
+    <$> many
+      ( option (eitherReader $ parseOpt variableP)
+          $ mconcat
+            [ long "var"
+            , helpDoc
+                $ Just
+                $ vsep
+                  [ "Variables to use in expansion"
+                  , indent 2
+                      $ vsep
+                        [ "--var 'name := \"scalar\"'"
+                        , "--var 'name := (\"scalar\", ...)'"
+                        , "--var 'name := [(\"scalar\",\"scalar\"), ...)'"
+                        , ""
+                        ]
+                  ]
+            , metavar "ARG"
+            ]
+      )
+    <*> argument
+      (eitherReader $ parseOpt templateP)
+      ( mconcat
+          [ metavar "TEMPLATE"
+          , help "URI template to expand"
+          ]
+      )
+
+main :: IO ()
+main = do
+  opts <- parseOptions
+
+  tty <- hIsTerminalDevice stdout
+
+  let
+    render =
+      if tty
+        then Ansi.renderStrict
+        else Text.renderStrict
+
+    maxWidth =
+      maximum
+        $ map (T.length . unVarName)
+        $ Map.keys opts.variables
+
+  T.putStrLn
+    $ render
+    $ layoutPretty defaultLayoutOptions
+    $ reAnnotate annToAnsi
+    $ vsep
+      [ sectionPretty "Variables"
+          $ reAnnotate AnnTemplate
+          $ vsep
+          $ map (uncurry $ variablePretty maxWidth)
+          $ Map.toList opts.variables
+      , ""
+      , sectionPretty "Template"
+          $ reAnnotate AnnTemplate
+          $ templatePretty opts.template
+      , ""
+      , sectionPretty "Expanded"
+          $ pretty
+          $ expandTemplate opts.variables opts.template
+      ]
+
+sectionPretty :: Text -> Doc Ann -> Doc Ann
+sectionPretty name d =
+  vsep
+    [ annotate AnnLabel (pretty name) <> ":"
+    , indent 2 d
+    ]
+
+data Ann
+  = AnnLabel
+  | AnnTemplate Template.Ann
+
+annToAnsi :: Ann -> Ansi.AnsiStyle
+annToAnsi = \case
+  AnnLabel -> Ansi.bold
+  AnnTemplate x -> case x of
+    AnnPunctuation -> Ansi.bold
+    AnnOperator -> Ansi.color Ansi.Magenta
+    AnnVarName -> Ansi.colorDull Ansi.Cyan
+    AnnModifier -> Ansi.colorDull Ansi.Green
+    AnnString -> Ansi.italicized <> Ansi.colorDull Ansi.Green
diff --git a/network-uri-template.cabal b/network-uri-template.cabal
new file mode 100644
--- /dev/null
+++ b/network-uri-template.cabal
@@ -0,0 +1,170 @@
+cabal-version:   1.18
+name:            network-uri-template
+version:         0.1.0.0
+license:         AGPL-3
+maintainer:      Pat Brisbin
+synopsis:        TODO
+description:     Please see README.md
+build-type:      Simple
+extra-doc-files:
+    README.md
+    CHANGELOG.md
+
+library
+    exposed-modules:
+        Network.URI.Template
+        Network.URI.Template.Expand
+        Network.URI.Template.Internal
+        Network.URI.Template.Internal.Expression
+        Network.URI.Template.Internal.Modifier
+        Network.URI.Template.Internal.Operator
+        Network.URI.Template.Internal.Parse
+        Network.URI.Template.Internal.Pretty
+        Network.URI.Template.Internal.TemplatePart
+        Network.URI.Template.Internal.VarSpec
+        Network.URI.Template.Parse
+        Network.URI.Template.VarName
+        Network.URI.Template.VarValue
+
+    hs-source-dirs:     src
+    other-modules:      Paths_network_uri_template
+    default-language:   GHC2021
+    default-extensions:
+        DataKinds DeriveAnyClass DerivingStrategies DerivingVia
+        DuplicateRecordFields GADTs LambdaCase NoFieldSelectors
+        NoImplicitPrelude NoMonomorphismRestriction OverloadedRecordDot
+        OverloadedStrings RecordWildCards TypeFamilies QuasiQuotes
+
+    ghc-options:
+        -fwrite-ide-info -Weverything -Wno-all-missed-specialisations
+        -Wno-missed-specialisations -Wno-missing-exported-signatures
+        -Wno-missing-import-lists -Wno-missing-local-signatures
+        -Wno-missing-safe-haskell-mode -Wno-monomorphism-restriction
+        -Wno-prepositive-qualified-module -Wno-safe -Wno-unsafe
+        -optP-Wno-nonportable-include-path
+
+    build-depends:
+        base >=4.20.2.0 && <5,
+        containers >=0.7,
+        megaparsec >=9.7.0,
+        network-uri >=2.6.4.2,
+        prettyprinter >=1.7.0,
+        text >=2.1.3
+
+    if impl(ghc >=9.8)
+        ghc-options: -Wno-missing-role-annotations
+
+    if impl(ghc >=9.2)
+        ghc-options: -Wno-missing-kind-signatures
+
+executable network-uri-template
+    main-is:            Main.hs
+    hs-source-dirs:     app
+    other-modules:      Paths_network_uri_template
+    default-language:   GHC2021
+    default-extensions:
+        DataKinds DeriveAnyClass DerivingStrategies DerivingVia
+        DuplicateRecordFields GADTs LambdaCase NoFieldSelectors
+        NoImplicitPrelude NoMonomorphismRestriction OverloadedRecordDot
+        OverloadedStrings RecordWildCards TypeFamilies QuasiQuotes
+
+    ghc-options:
+        -fwrite-ide-info -Weverything -Wno-all-missed-specialisations
+        -Wno-missed-specialisations -Wno-missing-exported-signatures
+        -Wno-missing-import-lists -Wno-missing-local-signatures
+        -Wno-missing-safe-haskell-mode -Wno-monomorphism-restriction
+        -Wno-prepositive-qualified-module -Wno-safe -Wno-unsafe
+        -optP-Wno-nonportable-include-path -threaded -rtsopts
+        -with-rtsopts=-N
+
+    build-depends:
+        base >=4.20.2.0 && <5,
+        containers >=0.7,
+        network-uri-template,
+        optparse-applicative >=0.18.1.0,
+        prettyprinter >=1.7.1,
+        prettyprinter-ansi-terminal >=1.1.3,
+        text >=2.1.3
+
+    if impl(ghc >=9.8)
+        ghc-options: -Wno-missing-role-annotations
+
+    if impl(ghc >=9.2)
+        ghc-options: -Wno-missing-kind-signatures
+
+test-suite readme
+    type:               exitcode-stdio-1.0
+    main-is:            README.lhs
+    build-tool-depends: markdown-unlit:markdown-unlit
+    other-modules:      Paths_network_uri_template
+    default-language:   GHC2021
+    default-extensions:
+        DataKinds DeriveAnyClass DerivingStrategies DerivingVia
+        DuplicateRecordFields GADTs LambdaCase NoFieldSelectors
+        NoImplicitPrelude NoMonomorphismRestriction OverloadedRecordDot
+        OverloadedStrings RecordWildCards TypeFamilies QuasiQuotes
+
+    ghc-options:
+        -fwrite-ide-info -Weverything -Wno-all-missed-specialisations
+        -Wno-missed-specialisations -Wno-missing-exported-signatures
+        -Wno-missing-import-lists -Wno-missing-local-signatures
+        -Wno-missing-safe-haskell-mode -Wno-monomorphism-restriction
+        -Wno-prepositive-qualified-module -Wno-safe -Wno-unsafe
+        -optP-Wno-nonportable-include-path -pgmL markdown-unlit
+
+    build-depends:
+        base >=4.20.2.0 && <5,
+        containers >=0.7,
+        network-uri-template,
+        text >=2.1.3
+
+    if impl(ghc >=9.8)
+        ghc-options: -Wno-missing-role-annotations
+
+    if impl(ghc >=9.2)
+        ghc-options: -Wno-missing-kind-signatures
+
+test-suite spec
+    type:               exitcode-stdio-1.0
+    main-is:            Spec.hs
+    hs-source-dirs:     test
+    other-modules:
+        Network.URI.Template.Internal.ExpressionSpec
+        Network.URI.Template.Internal.TemplatePartSpec
+        Network.URI.Template.Internal.VarSpecSpec
+        Network.URI.Template.Test
+        Network.URI.Template.Test.RFC
+        Network.URI.Template.VarNameSpec
+        Network.URI.TemplateSpec
+        Paths_network_uri_template
+
+    default-language:   GHC2021
+    default-extensions:
+        DataKinds DeriveAnyClass DerivingStrategies DerivingVia
+        DuplicateRecordFields GADTs LambdaCase NoFieldSelectors
+        NoImplicitPrelude NoMonomorphismRestriction OverloadedRecordDot
+        OverloadedStrings RecordWildCards TypeFamilies QuasiQuotes
+
+    ghc-options:
+        -fwrite-ide-info -Weverything -Wno-all-missed-specialisations
+        -Wno-missed-specialisations -Wno-missing-exported-signatures
+        -Wno-missing-import-lists -Wno-missing-local-signatures
+        -Wno-missing-safe-haskell-mode -Wno-monomorphism-restriction
+        -Wno-prepositive-qualified-module -Wno-safe -Wno-unsafe
+        -optP-Wno-nonportable-include-path -threaded -rtsopts
+        -with-rtsopts=-N
+
+    build-depends:
+        base >=4.20.2.0 && <5,
+        conduit >=1.3.6.1,
+        containers >=0.7,
+        hspec >=2.11.14,
+        megaparsec >=9.7.0,
+        network-uri-template,
+        text >=2.1.3
+
+    if impl(ghc >=9.8)
+        ghc-options: -Wno-missing-role-annotations
+
+    if impl(ghc >=9.2)
+        ghc-options: -Wno-missing-kind-signatures
diff --git a/src/Network/URI/Template.hs b/src/Network/URI/Template.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/URI/Template.hs
@@ -0,0 +1,41 @@
+-- |
+--
+-- Module      : Network.URI.Template
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template
+  ( Template
+  , TemplateError (..)
+  , templateErrorPretty
+  , processTemplate
+
+    -- * Template variables
+  , module Network.URI.Template.VarName
+  , module Network.URI.Template.VarValue
+  ) where
+
+import Prelude
+
+import Data.Bifunctor (first)
+import Data.Map.Strict (Map)
+import Data.Text (Text)
+import Network.URI.Template.Expand
+import Network.URI.Template.Internal
+import Network.URI.Template.Internal.Parse
+import Network.URI.Template.Parse
+import Network.URI.Template.VarName
+import Network.URI.Template.VarValue
+
+newtype TemplateError = TemplateParseError ParseError
+
+templateErrorPretty :: TemplateError -> String
+templateErrorPretty = \case
+  TemplateParseError pe -> errorBundlePretty pe
+
+processTemplate :: Map VarName VarValue -> Text -> Either TemplateError Text
+processTemplate env t = do
+  template <- first TemplateParseError $ parseTemplate t
+  pure $ expandTemplate env template
diff --git a/src/Network/URI/Template/Expand.hs b/src/Network/URI/Template/Expand.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/URI/Template/Expand.hs
@@ -0,0 +1,27 @@
+-- |
+--
+-- Module      : Network.URI.Template.Expand
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.Expand
+  ( expandTemplate
+  ) where
+
+import Prelude
+
+import Data.Map.Strict (Map)
+import Data.Text (Text)
+import Network.URI.Template.Internal
+import Network.URI.Template.VarName
+import Network.URI.Template.VarValue
+
+expandTemplate :: Map VarName VarValue -> Template -> Text
+expandTemplate env = foldMap (expandTemplatePart env) . (.unwrap)
+
+expandTemplatePart :: Map VarName VarValue -> TemplatePart -> Text
+expandTemplatePart env = \case
+  Lit t -> t
+  Exp e -> expandExpression env e
diff --git a/src/Network/URI/Template/Internal.hs b/src/Network/URI/Template/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/URI/Template/Internal.hs
@@ -0,0 +1,61 @@
+-- |
+--
+-- Module      : Network.URI.Template.Internal
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.Internal
+  ( Template (..)
+  , templateP
+  , templatePretty
+  , variableP
+  , variablePretty
+  , module Network.URI.Template.Internal.Expression
+  , module Network.URI.Template.Internal.Modifier
+  , module Network.URI.Template.Internal.Operator
+  , module Network.URI.Template.Internal.TemplatePart
+  , module Network.URI.Template.Internal.VarSpec
+  ) where
+
+import Prelude
+
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Network.URI.Template.Internal.Expression
+import Network.URI.Template.Internal.Modifier
+import Network.URI.Template.Internal.Operator
+import Network.URI.Template.Internal.Parse
+import Network.URI.Template.Internal.Pretty
+import Network.URI.Template.Internal.TemplatePart
+import Network.URI.Template.Internal.VarSpec
+import Network.URI.Template.VarName
+import Network.URI.Template.VarValue
+
+newtype Template = Template
+  { unwrap :: [TemplatePart]
+  }
+  deriving stock (Eq, Show)
+
+templateP :: Parser Template
+templateP = Template <$> some templatePartP
+
+templatePretty :: Template -> Doc Ann
+templatePretty = mconcat . map templatePartPretty . (.unwrap)
+
+-- | Parse one @name := value@ assignment
+variableP :: Parser (Map VarName VarValue)
+variableP =
+  Map.singleton
+    <$> varNameP
+    <*> (hspace *> string ":=" *> hspace *> varValueP)
+
+-- | Prettyprint a variable and value, 'fill'ing the name to the given width
+variablePretty :: Int -> VarName -> VarValue -> Doc Ann
+variablePretty w n v =
+  hsep
+    [ fill w $ varNamePretty n
+    , annotate AnnPunctuation ":="
+    , varValuePretty v
+    ]
diff --git a/src/Network/URI/Template/Internal/Expression.hs b/src/Network/URI/Template/Internal/Expression.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/URI/Template/Internal/Expression.hs
@@ -0,0 +1,63 @@
+-- |
+--
+-- Module      : Network.URI.Template.Internal.Expression
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.Internal.Expression
+  ( Expression (..)
+  , expressionP
+  , expressionPretty
+  , expandExpression
+  ) where
+
+import Prelude
+
+import Data.Map.Strict (Map)
+import Data.Text (Text)
+import Data.Text qualified as T
+import Network.URI.Template.Internal.Operator
+import Network.URI.Template.Internal.Parse
+import Network.URI.Template.Internal.Pretty
+import Network.URI.Template.Internal.VarSpec
+import Network.URI.Template.VarName
+import Network.URI.Template.VarValue
+
+data Expression = Expression
+  { operator :: Maybe Operator
+  , variableList :: [VarSpec]
+  }
+  deriving stock (Eq, Show)
+
+-- |
+--
+-- @
+-- expression    =  "{" [ operator ] variable-list "}"
+-- variable-list =  varspec *( "," varspec )
+-- @
+expressionP :: Parser Expression
+expressionP =
+  Expression
+    <$> (char '{' *> optional operatorP)
+    <*> (sepBy1 varSpecP (char ',') <* char '}')
+
+expressionPretty :: Expression -> Doc Ann
+expressionPretty e = do
+  annotate AnnPunctuation "{"
+    <> maybe mempty (annotate AnnOperator . operatorPretty) e.operator
+    <> hcat (punctuate "," $ map varSpecPretty e.variableList)
+    <> annotate AnnPunctuation "}"
+
+expandExpression :: Map VarName VarValue -> Expression -> Text
+expandExpression env e =
+  prefix <> T.intercalate oa.listIntercalate vars
+ where
+  oa = maybe nullOperatorActions operatorActions e.operator
+
+  prefix
+    | null vars = ""
+    | otherwise = oa.listPrefix
+
+  vars = concatMap (expandVarSpec env oa) e.variableList
diff --git a/src/Network/URI/Template/Internal/Modifier.hs b/src/Network/URI/Template/Internal/Modifier.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/URI/Template/Internal/Modifier.hs
@@ -0,0 +1,47 @@
+-- |
+--
+-- Module      : Network.URI.Template.Internal.Modifier
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.Internal.Modifier
+  ( Modifier (..)
+  , modifierP
+  , modifierPretty
+  ) where
+
+import Prelude
+
+import Network.URI.Template.Internal.Parse
+import Network.URI.Template.Internal.Pretty
+
+data Modifier
+  = Prefix Int
+  | Explode
+  deriving stock (Eq, Show)
+
+-- |
+--
+-- @
+-- modifier-level4 =  prefix / explode
+--
+-- prefix     =  ":" max-length
+-- max-length =  %x31-39 0*3DIGIT   ; positive integer < 10000
+--
+-- explode = *
+-- @
+modifierP :: Parser Modifier
+modifierP = prefixP <|> explodeP
+
+modifierPretty :: Modifier -> Doc ann
+modifierPretty = \case
+  Prefix n -> ":" <> pretty n
+  Explode -> "*"
+
+prefixP :: Parser Modifier
+prefixP = char ':' *> (Prefix . read <$> some digitChar) <?> "prefix modifier"
+
+explodeP :: Parser Modifier
+explodeP = Explode <$ char '*' <?> "explode modifier"
diff --git a/src/Network/URI/Template/Internal/Operator.hs b/src/Network/URI/Template/Internal/Operator.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/URI/Template/Internal/Operator.hs
@@ -0,0 +1,137 @@
+-- |
+--
+-- Module      : Network.URI.Template.Internal.Operator
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.Internal.Operator
+  ( Operator (..)
+  , operatorP
+  , operatorPretty
+  , OperatorActions (..)
+  , operatorActions
+  , nullOperatorActions
+  ) where
+
+import Prelude
+
+import Data.Text (Text, pack, unpack)
+import Data.Text qualified as T
+import Network.URI (escapeURIString, isReserved, isUnescapedInURIComponent)
+import Network.URI.Template.Internal.Parse
+import Network.URI.Template.Internal.Pretty
+import Network.URI.Template.VarName
+
+data Operator
+  = Reserved
+  | Fragments
+  | Labels
+  | PathSegments
+  | PathParameters
+  | Query
+  | QueryContinuation
+  | SpecReserved Char
+  deriving stock (Eq, Show)
+
+-- |
+--
+-- @
+-- operator      =  op-level2 / op-level3 / op-reserve
+-- op-level2     =  "+" / "#"
+-- op-level3     =  "." / "/" / ";" / "?" / "&"
+-- op-reserve    =  "=" / "," / "!" / "@" / "|"
+-- @
+operatorP :: Parser Operator
+operatorP =
+  choice
+    [ Reserved <$ char '+' <?> "reserved operator"
+    , Fragments <$ char '#' <?> "fragment operator"
+    , Labels <$ char '.' <?> "label operator"
+    , PathSegments <$ char '/' <?> "path segment operator"
+    , PathParameters <$ char ';' <?> "path parameter operator"
+    , Query <$ char '?' <?> "query operator"
+    , QueryContinuation <$ char '&' <?> "query continuation operator"
+    , SpecReserved <$> oneOf ("=,!@|" :: String) <?> "spec-reserved operator"
+    ]
+
+operatorPretty :: Operator -> Doc ann
+operatorPretty = \case
+  Reserved -> "+"
+  Fragments -> "#"
+  Labels -> "."
+  PathSegments -> "/"
+  PathParameters -> ";"
+  Query -> "?"
+  QueryContinuation -> "&"
+  SpecReserved c -> pretty c
+
+data OperatorActions = OperatorActions
+  { listPrefix :: Text
+  , listIntercalate :: Text
+  , renderValue :: VarName -> Text -> Text
+  , escapeValue :: Text -> Text
+  }
+
+-- | 'OperatorActions' to use when there was no 'Operator'
+nullOperatorActions :: OperatorActions
+nullOperatorActions =
+  OperatorActions
+    { listPrefix = ""
+    , listIntercalate = ","
+    , escapeValue = escapeURIText isUnescapedInURIComponent
+    , renderValue = \_ v -> v
+    }
+
+operatorActions :: Operator -> OperatorActions
+operatorActions = \case
+  Reserved ->
+    nullOperatorActions
+      { escapeValue = escapeURIText $ \c ->
+          or
+            [ isUnescapedInURIComponent c
+            , isReserved c
+            ]
+      }
+  Fragments ->
+    nullOperatorActions
+      { listPrefix = "#"
+      , escapeValue = escapeURIText $ \c ->
+          or
+            [ isUnescapedInURIComponent c
+            , isReserved c
+            ]
+      }
+  Labels ->
+    nullOperatorActions
+      { listPrefix = "."
+      , listIntercalate = "."
+      }
+  PathSegments ->
+    nullOperatorActions
+      { listPrefix = "/"
+      , listIntercalate = "/"
+      }
+  PathParameters ->
+    nullOperatorActions
+      { listPrefix = ";"
+      , listIntercalate = ";"
+      , renderValue = \k v -> unVarName k <> if T.null v then "" else "=" <> v
+      }
+  Query ->
+    nullOperatorActions
+      { listPrefix = "?"
+      , listIntercalate = "&"
+      , renderValue = \k v -> unVarName k <> "=" <> v
+      }
+  QueryContinuation ->
+    nullOperatorActions
+      { listPrefix = "&"
+      , listIntercalate = "&"
+      , renderValue = \k v -> unVarName k <> "=" <> v
+      }
+  SpecReserved {} -> nullOperatorActions
+
+escapeURIText :: (Char -> Bool) -> Text -> Text
+escapeURIText p = pack . escapeURIString p . unpack
diff --git a/src/Network/URI/Template/Internal/Parse.hs b/src/Network/URI/Template/Internal/Parse.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/URI/Template/Internal/Parse.hs
@@ -0,0 +1,64 @@
+-- |
+--
+-- Module      : Network.URI.Template.Internal.Parse
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.Internal.Parse
+  ( -- * Parsing
+    Parser
+  , parse
+  , parseOpt
+
+    -- * Errors
+  , ParseError
+  , errorBundlePretty
+
+    -- * Re-exports
+  , module Text.Megaparsec
+  , module Text.Megaparsec.Char
+
+    -- * Extensions
+  , quoted
+  , restOfLine
+  ) where
+
+import Prelude
+
+import Control.Monad (void)
+import Data.Text (Text, pack)
+import Data.Void (Void)
+import Text.Megaparsec hiding (ParseError, errorBundlePretty, parse)
+import Text.Megaparsec qualified as Megaparsec
+import Text.Megaparsec.Char
+
+type Parser = Parsec Void Text
+
+type ParseError = ParseErrorBundle Text Void
+
+errorBundlePretty :: ParseError -> String
+errorBundlePretty = Megaparsec.errorBundlePretty
+
+parse :: Parser a -> Text -> Either ParseError a
+parse p = Megaparsec.parse p "<uri>"
+
+parseOpt :: Parser a -> String -> Either String a
+parseOpt p s = case Megaparsec.parse p "<input>" $ pack s of
+  Left err ->
+    Left
+      $ unlines
+        [ "Unable to parse option"
+        , "input: " <> s
+        , "error:"
+        , Megaparsec.errorBundlePretty err
+        ]
+  Right a -> Right a
+
+-- | NOTE: doesn't handle escaping
+quoted :: Parser Text
+quoted = pack <$> (char '"' *> manyTill anySingle (char '"'))
+
+restOfLine :: Parser Text
+restOfLine = pack <$> manyTill anySingle (lookAhead $ void eol <|> eof)
diff --git a/src/Network/URI/Template/Internal/Pretty.hs b/src/Network/URI/Template/Internal/Pretty.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/URI/Template/Internal/Pretty.hs
@@ -0,0 +1,30 @@
+-- |
+--
+-- Module      : Network.URI.Template.Internal.Pretty
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.Internal.Pretty
+  ( Ann (..)
+  , renderPlain
+
+    -- * Re-exports
+  , module Prettyprinter
+  ) where
+
+import Prelude
+
+import Prettyprinter
+import Prettyprinter.Render.String
+
+data Ann
+  = AnnPunctuation
+  | AnnOperator
+  | AnnVarName
+  | AnnModifier
+  | AnnString
+
+renderPlain :: Doc ann -> String
+renderPlain = renderString . layoutPretty defaultLayoutOptions
diff --git a/src/Network/URI/Template/Internal/TemplatePart.hs b/src/Network/URI/Template/Internal/TemplatePart.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/URI/Template/Internal/TemplatePart.hs
@@ -0,0 +1,40 @@
+-- |
+--
+-- Module      : Network.URI.Template.Internal.TemplatePart
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.Internal.TemplatePart
+  ( TemplatePart (..)
+  , templatePartP
+  , templatePartPretty
+  ) where
+
+import Prelude
+
+import Data.Text (Text, pack)
+import Network.URI.Template.Internal.Expression
+import Network.URI.Template.Internal.Parse
+import Network.URI.Template.Internal.Pretty
+
+data TemplatePart
+  = Lit Text
+  | Exp Expression
+  deriving stock (Eq, Show)
+
+templatePartP :: Parser TemplatePart
+templatePartP =
+  choice
+    [ Exp <$> expressionP <?> "template expression"
+    , Lit . pack <$> some litChar <?> "template literal"
+    ]
+ where
+  litChar :: Parser Char
+  litChar = noneOf ['\r', '\n', '\t', ' ', '{']
+
+templatePartPretty :: TemplatePart -> Doc Ann
+templatePartPretty = \case
+  Lit t -> pretty t
+  Exp e -> expressionPretty e
diff --git a/src/Network/URI/Template/Internal/VarSpec.hs b/src/Network/URI/Template/Internal/VarSpec.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/URI/Template/Internal/VarSpec.hs
@@ -0,0 +1,72 @@
+-- |
+--
+-- Module      : Network.URI.Template.Internal.VarSpec
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.Internal.VarSpec
+  ( VarSpec (..)
+  , varSpecP
+  , varSpecPretty
+  , expandVarSpec
+  ) where
+
+import Prelude
+
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Data.Text (Text)
+import Data.Text qualified as T
+import Network.URI.Template.Internal.Modifier
+import Network.URI.Template.Internal.Operator
+import Network.URI.Template.Internal.Parse
+import Network.URI.Template.Internal.Pretty
+import Network.URI.Template.VarName
+import Network.URI.Template.VarValue
+
+data VarSpec = VarSpec
+  { name :: VarName
+  , modifier :: Maybe Modifier
+  }
+  deriving stock (Eq, Show)
+
+-- |
+--
+-- @
+-- varspec       =  varname [ modifier-level4 ]
+-- @
+varSpecP :: Parser VarSpec
+varSpecP =
+  VarSpec
+    <$> varNameP
+    <*> optional modifierP
+
+varSpecPretty :: VarSpec -> Doc Ann
+varSpecPretty v =
+  annotate AnnVarName (pretty $ unVarName v.name)
+    <> maybe mempty (annotate AnnModifier . modifierPretty) v.modifier
+
+expandVarSpec :: Map VarName VarValue -> OperatorActions -> VarSpec -> [Text]
+expandVarSpec vars oa vs = maybe [] renderValues $ Map.lookup vs.name vars
+ where
+  renderValues :: VarValue -> [Text]
+  renderValues = \case
+    VarNull -> []
+    VarValue t
+      | Just (Prefix n) <- vs.modifier -> renderValueEsc $ T.take n t
+      | otherwise -> renderValueEsc t
+    VarList ts
+      | null ts -> []
+      | Just Explode <- vs.modifier -> concatMap renderValueEsc ts
+      | otherwise -> renderValueCsv $ map oa.escapeValue ts
+    VarMap kvs
+      | null kvs -> []
+      | Just Explode <- vs.modifier -> map (uncurry $ renderKeyValue '=') kvs
+      | otherwise -> renderValueCsv $ map (uncurry $ renderKeyValue ',') kvs
+
+  renderValue = pure . oa.renderValue vs.name
+  renderValueEsc = renderValue . oa.escapeValue
+  renderValueCsv = renderValue . T.intercalate ","
+  renderKeyValue c k v = oa.escapeValue k <> T.singleton c <> oa.escapeValue v
diff --git a/src/Network/URI/Template/Parse.hs b/src/Network/URI/Template/Parse.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/URI/Template/Parse.hs
@@ -0,0 +1,24 @@
+-- |
+--
+-- Module      : Network.URI.Template.Parse
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.Parse
+  ( parseTemplate
+
+    -- * Errors
+  , ParseError
+  , errorBundlePretty
+  ) where
+
+import Prelude
+
+import Data.Text (Text)
+import Network.URI.Template.Internal
+import Network.URI.Template.Internal.Parse
+
+parseTemplate :: Text -> Either ParseError Template
+parseTemplate = parse templateP
diff --git a/src/Network/URI/Template/VarName.hs b/src/Network/URI/Template/VarName.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/URI/Template/VarName.hs
@@ -0,0 +1,82 @@
+-- |
+--
+-- Module      : Network.URI.Template.VarName
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.VarName
+  ( VarName
+  , varNameP
+  , varNamePretty
+  , readVarName
+  , unVarName
+  ) where
+
+import Prelude
+
+import Data.Bifunctor (first)
+import Data.String (IsString (..))
+import Data.Text (Text, pack)
+import Network.URI.Template.Internal.Parse
+import Network.URI.Template.Internal.Pretty
+
+newtype VarName = VarName
+  { unwrap :: Text
+  }
+  deriving stock (Eq, Ord, Show)
+
+instance IsString VarName where
+  fromString = either error id . readVarName
+
+readVarName :: String -> Either String VarName
+readVarName = first errorBundlePretty . parse (varNameP <* eof) . pack
+
+unVarName :: VarName -> Text
+unVarName = (.unwrap)
+
+-- |
+--
+-- @
+-- varname       =  varchar *( ["."] varchar )
+-- varchar       =  ALPHA / DIGIT / "_" / pct-encoded
+-- @
+varNameP :: Parser VarName
+varNameP = (<?> "variable name") $ do
+  v <- varCharP
+  vs <- many $ (,) <$> optional (char '.') <*> varCharP
+
+  pure
+    $ VarName
+    $ foldMap
+      ( \case
+          (mc, VarChar c) -> pack $ maybe id (:) mc [c]
+          (mc, PctEncoded s) -> pack $ maybe id (:) mc s
+      )
+    $ (Nothing, v) : vs
+
+varNamePretty :: VarName -> Doc Ann
+varNamePretty = annotate AnnVarName . pretty . unVarName
+
+data VarChar
+  = VarChar Char
+  | PctEncoded String
+
+varCharP :: Parser VarChar
+varCharP =
+  choice
+    [ VarChar <$> alphaNumChar
+    , VarChar <$> char '_'
+    , PctEncoded <$> pcntEncodedP
+    ]
+    <?> "variable character"
+
+pcntEncodedP :: Parser String
+pcntEncodedP =
+  sequenceA
+    [ char '%'
+    , hexDigitChar
+    , hexDigitChar
+    ]
+    <?> "percent-encoded triplet"
diff --git a/src/Network/URI/Template/VarValue.hs b/src/Network/URI/Template/VarValue.hs
new file mode 100644
--- /dev/null
+++ b/src/Network/URI/Template/VarValue.hs
@@ -0,0 +1,100 @@
+-- |
+--
+-- Module      : Network.URI.Template.VarValue
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.VarValue
+  ( VarValue (..)
+  , varValueP
+  , varValuePretty
+  ) where
+
+import Prelude
+
+import Data.String (IsString (..))
+import Data.Text (Text, pack)
+import Network.URI.Template.Internal.Parse
+import Network.URI.Template.Internal.Pretty
+
+data VarValue
+  = VarNull
+  | VarValue Text
+  | VarList [Text]
+  | VarMap [(Text, Text)]
+  deriving stock (Show)
+
+instance IsString VarValue where
+  fromString = VarValue . pack
+
+varValueP :: Parser VarValue
+varValueP =
+  choice
+    [ varMapP <?> "map variable value"
+    , varListP <?> "list variable value"
+    , varStringP <?> "string variable value"
+    , varNumberP <?> "numeric variable value"
+    , varNullP <?> "null variable value"
+    ]
+
+varMapP :: Parser VarValue
+varMapP = VarMap <$> csvP '[' ']' kvP
+
+kvP :: Parser (Text, Text)
+kvP =
+  (,)
+    <$> (char '(' *> hspace *> quoted <* commaP)
+    <*> (quoted <* hspace <* char ')')
+
+varListP :: Parser VarValue
+varListP = VarList <$> csvP '(' ')' quoted
+
+varStringP :: Parser VarValue
+varStringP = VarValue <$> quoted
+
+varNumberP :: Parser VarValue
+varNumberP = VarValue . pack <$> some digitChar
+
+varNullP :: Parser VarValue
+varNullP = VarNull <$ string "null"
+
+csvP :: Char -> Char -> Parser a -> Parser [a]
+csvP l r p = char l *> hspace *> sepBy p commaP <* hspace <* char r
+
+commaP :: Parser Char
+commaP = try (hspace *> char ',' <* hspace) <?> "comma"
+
+varValuePretty :: VarValue -> Doc Ann
+varValuePretty = \case
+  VarNull -> annotate AnnVarName "null"
+  VarValue t -> stPretty t
+  VarList ts -> csvPretty "(" ")" $ map stPretty ts
+  VarMap kvs -> csvPretty "[" "]" $ map (uncurry kvPretty) kvs
+
+csvPretty :: Text -> Text -> [Doc Ann] -> Doc Ann
+csvPretty l r ds =
+  hcat
+    [ annotate AnnPunctuation $ pretty l
+    , hsep $ punctuate (annotate AnnPunctuation comma) ds
+    , annotate AnnPunctuation $ pretty r
+    ]
+
+stPretty :: Text -> Doc Ann
+stPretty t =
+  hcat
+    [ annotate AnnPunctuation "\""
+    , annotate AnnString $ pretty t
+    , annotate AnnPunctuation "\""
+    ]
+
+kvPretty :: Text -> Text -> Doc Ann
+kvPretty k v =
+  hcat
+    [ annotate AnnPunctuation "("
+    , annotate AnnVarName (pretty k)
+    , annotate AnnPunctuation ","
+    , stPretty v
+    , annotate AnnPunctuation ")"
+    ]
diff --git a/test/Network/URI/Template/Internal/ExpressionSpec.hs b/test/Network/URI/Template/Internal/ExpressionSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Network/URI/Template/Internal/ExpressionSpec.hs
@@ -0,0 +1,40 @@
+-- |
+--
+-- Module      : Network.URI.Template.Internal.ExpressionSpec
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.Internal.ExpressionSpec
+  ( spec
+  ) where
+
+import Prelude
+
+import Network.URI.Template.Internal.Expression
+import Network.URI.Template.Internal.Modifier
+import Network.URI.Template.Internal.Operator
+import Network.URI.Template.Internal.VarSpec
+import Network.URI.Template.Test
+import Test.Hspec
+
+spec :: Spec
+spec = do
+  describe "expressionP" $ do
+    it "parses a query variable"
+      $ assertParse expressionP "{?var}"
+      $ Expression
+        { operator = Just Query
+        , variableList = [VarSpec {name = "var", modifier = Nothing}]
+        }
+
+    it "parses multiple variables"
+      $ assertParse expressionP "{?var,var*}"
+      $ Expression
+        { operator = Just Query
+        , variableList =
+            [ VarSpec {name = "var", modifier = Nothing}
+            , VarSpec {name = "var", modifier = Just Explode}
+            ]
+        }
diff --git a/test/Network/URI/Template/Internal/TemplatePartSpec.hs b/test/Network/URI/Template/Internal/TemplatePartSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Network/URI/Template/Internal/TemplatePartSpec.hs
@@ -0,0 +1,84 @@
+-- |
+--
+-- Module      : Network.URI.Template.Internal.TemplatePartSpec
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.Internal.TemplatePartSpec
+  ( spec
+  ) where
+
+import Prelude
+
+import Network.URI.Template.Internal.Expression
+import Network.URI.Template.Internal.Operator
+import Network.URI.Template.Internal.Parse
+import Network.URI.Template.Internal.TemplatePart
+import Network.URI.Template.Internal.VarSpec
+import Network.URI.Template.Test
+import Test.Hspec
+
+spec :: Spec
+spec = do
+  describe "templatePartP" $ do
+    it "parses a URI with query variable"
+      $ assertParse
+        (some templatePartP)
+        "foo/{?var}&bat"
+        [ Lit "foo/"
+        , Exp
+            $ Expression
+              { operator = Just Query
+              , variableList = [VarSpec {name = "var", modifier = Nothing}]
+              }
+        , Lit "&bat"
+        ]
+
+    it "parses a template with literal at end of input" $ do
+      assertParse
+        (some templatePartP <* eof)
+        "{&who}X"
+        [ Exp
+            $ Expression
+              { operator = Just QueryContinuation
+              , variableList = [VarSpec {name = "who", modifier = Nothing}]
+              }
+        , Lit "X"
+        ]
+
+    it "parses a template at end of line" $ do
+      assertParse
+        (sepEndBy (some templatePartP) eol <* eof)
+        "{&who}X\n"
+        [
+          [ Exp
+              $ Expression
+                { operator = Just QueryContinuation
+                , variableList = [VarSpec {name = "who", modifier = Nothing}]
+                }
+          , Lit "X"
+          ]
+        ]
+
+    it "parses a template within RFC examples" $ do
+      let
+        p =
+          (,)
+            <$> (hspace1 *> some templatePartP)
+            <*> (hspace1 *> manyTill anySingle eof)
+
+        input = "  {&who}  &who=fred"
+        expected =
+          (
+            [ Exp
+                $ Expression
+                  { operator = Just QueryContinuation
+                  , variableList = [VarSpec {name = "who", modifier = Nothing}]
+                  }
+            ]
+          , "&who=fred"
+          )
+
+      assertParse p input expected
diff --git a/test/Network/URI/Template/Internal/VarSpecSpec.hs b/test/Network/URI/Template/Internal/VarSpecSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Network/URI/Template/Internal/VarSpecSpec.hs
@@ -0,0 +1,42 @@
+-- |
+--
+-- Module      : Network.URI.Template.Internal.VarSpecSpec
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.Internal.VarSpecSpec
+  ( spec
+  ) where
+
+import Prelude
+
+import Network.URI.Template.Internal.Modifier
+import Network.URI.Template.Internal.VarSpec
+import Network.URI.Template.Test
+import Test.Hspec
+
+spec :: Spec
+spec = do
+  describe "varSpecP" $ do
+    it "parses a variable without modifier"
+      $ assertParse varSpecP "var"
+      $ VarSpec
+        { name = "var"
+        , modifier = Nothing
+        }
+
+    it "parses a variable with prefix modifier"
+      $ assertParse varSpecP "var:3"
+      $ VarSpec
+        { name = "var"
+        , modifier = Just $ Prefix 3
+        }
+
+    it "parses a variable with explode modifier"
+      $ assertParse varSpecP "var*"
+      $ VarSpec
+        { name = "var"
+        , modifier = Just Explode
+        }
diff --git a/test/Network/URI/Template/Test.hs b/test/Network/URI/Template/Test.hs
new file mode 100644
--- /dev/null
+++ b/test/Network/URI/Template/Test.hs
@@ -0,0 +1,44 @@
+-- |
+--
+-- Module      : Network.URI.Template.Test
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.Test
+  ( assertParse
+  , refuteParse
+  , runRFCTests
+  ) where
+
+import Prelude
+
+import Data.Text (Text)
+import Network.URI.Template.Internal.Parse
+import Network.URI.Template.Test.RFC
+import Test.Hspec
+
+-- | Expect a parse to the given value
+assertParse
+  :: (Eq a, HasCallStack, Show a)
+  => Parser a
+  -> Text
+  -> a
+  -> Expectation
+assertParse p input expected =
+  case parse (p <* eof) input of
+    Left err -> expectationFailure $ "Expected parse, got error:\n" <> errorBundlePretty err
+    Right a -> a `shouldBe` expected
+
+-- | Expect a parse error that satisfies the given predicate
+refuteParse
+  :: (HasCallStack, Show a)
+  => Parser a
+  -> Text
+  -> (String -> Bool)
+  -> Expectation
+refuteParse p input f =
+  case parse (p <* eof) input of
+    Left err -> errorBundlePretty err `shouldSatisfy` f
+    Right a -> expectationFailure $ "Expected error, got parse:\n" <> show a
diff --git a/test/Network/URI/Template/Test/RFC.hs b/test/Network/URI/Template/Test/RFC.hs
new file mode 100644
--- /dev/null
+++ b/test/Network/URI/Template/Test/RFC.hs
@@ -0,0 +1,116 @@
+-- |
+--
+-- Module      : Network.URI.Template.Test.RFC
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.Test.RFC
+  ( runRFCTests
+  ) where
+
+import Prelude
+
+import Conduit
+import Control.Monad (void)
+import Data.Bifunctor (first)
+import Data.Foldable (fold, for_, traverse_)
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Data.Text (Text, unpack)
+import Data.Text qualified as T
+import Data.Text.IO qualified as T
+import Network.URI.Template.Expand
+import Network.URI.Template.Internal
+import Network.URI.Template.Internal.Parse (restOfLine)
+import Network.URI.Template.Internal.Pretty
+import Network.URI.Template.VarName
+import Network.URI.Template.VarValue
+import Test.Hspec
+import Text.Megaparsec
+import Text.Megaparsec.Char
+
+runRFCTests :: HasCallStack => Spec
+runRFCTests = do
+  ecases <- runIO parseTestCases
+  either (it "parsed examples" . expectationFailure) runTestCases ecases
+
+runTestCases :: [TestCase] -> Spec
+runTestCases tcs = traverse_ (runTestCase inputMaxWidth) tcs
+ where
+  inputMaxWidth =
+    maximum -- unsafe: we don't expect empty test cases
+      $ map (length . (.input))
+      $ concatMap (.examples) tcs
+
+runTestCase :: Int -> TestCase -> Spec
+runTestCase inputMaxWidth tc = do
+  context (renderPlain varDoc) $ do
+    for_ tc.examples $ \e -> do
+      it (pad inputMaxWidth e.input <> " => " <> unpack e.expected) $ do
+        expandTemplate tc.vars e.template `shouldBe` e.expected
+ where
+  varMaxWidth =
+    max inputMaxWidth
+      $ maximum -- unsafe: we don't expect empty variables
+      $ map (T.length . unVarName)
+      $ Map.keys tc.vars
+
+  varDoc =
+    vsep
+      [ "Variables"
+      , indent 6
+          $ vsep
+          $ map (uncurry $ variablePretty varMaxWidth)
+          $ Map.toList tc.vars
+      , indent 4 "Examples"
+      ]
+
+pad :: Int -> String -> String
+pad w s =
+  let n = w - length s
+  in  s <> if n > 0 then replicate n ' ' else ""
+
+data TestCase = TestCase
+  { vars :: Map VarName VarValue
+  , examples :: [TestExample]
+  }
+
+data TestExample = TestExample
+  { input :: String
+  , template :: Template
+  , expected :: Text
+  }
+
+parseTestCases :: IO (Either String [TestCase])
+parseTestCases = do
+  t <- T.readFile examples
+
+  pure
+    $ first (("RFC parse error:\n" <>) . errorBundlePretty)
+    $ parse (some testCaseP <* eof) examples t
+ where
+  examples :: FilePath
+  examples = "rfc/examples.txt"
+
+type Parser = Parsec Void Text
+
+testCaseP :: Parser TestCase
+testCaseP =
+  TestCase . fold
+    <$> someTill (taggedP 'V' variableP) (lookAhead $ void (char 'E') <|> eof)
+    <*> someTill (taggedP 'E' texampleP) (lookAhead $ void (char 'V') <|> eof)
+
+taggedP :: Char -> Parser a -> Parser a
+taggedP c p = char c *> char ' ' *> p <* hspace <* newline
+
+texampleP :: Parser TestExample
+texampleP = do
+  (template, expected) <- (,) <$> templateP <*> (char ' ' *> restOfLine)
+  pure
+    TestExample
+      { input = renderPlain $ templatePretty template
+      , template
+      , expected
+      }
diff --git a/test/Network/URI/Template/VarNameSpec.hs b/test/Network/URI/Template/VarNameSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Network/URI/Template/VarNameSpec.hs
@@ -0,0 +1,39 @@
+-- |
+--
+-- Module      : Network.URI.Template.VarNameSpec
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.Template.VarNameSpec
+  ( spec
+  ) where
+
+import Prelude
+
+import Data.List (isInfixOf)
+import Network.URI.Template.Test
+import Network.URI.Template.VarName
+import Test.Hspec
+
+spec :: Spec
+spec = do
+  describe "varNameP" $ do
+    it "parses a simple variable" $ do
+      assertParse varNameP "var" "var"
+
+    it "parses alphanumerics and _" $ do
+      assertParse varNameP "foo_123" "foo_123"
+
+    it "parses percent-encoding" $ do
+      assertParse varNameP "foo%25bar" "foo%25bar"
+
+    it "parses dot-separated" $ do
+      assertParse varNameP "foo.b.a.r" "foo.b.a.r"
+
+    it "rejects other characters" $ do
+      refuteParse varNameP "foo-bar" ("unexpected '-'" `isInfixOf`)
+
+    it "rejects consecutive dots" $ do
+      refuteParse varNameP "foo..bar" ("unexpected '.'" `isInfixOf`)
diff --git a/test/Network/URI/TemplateSpec.hs b/test/Network/URI/TemplateSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Network/URI/TemplateSpec.hs
@@ -0,0 +1,17 @@
+-- |
+--
+-- Module      : Network.URI.TemplateSpec
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
+module Network.URI.TemplateSpec
+  ( spec
+  ) where
+
+import Network.URI.Template.Test
+import Test.Hspec
+
+spec :: Spec
+spec = context "All RFC Examples" runRFCTests
diff --git a/test/Spec.hs b/test/Spec.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec.hs
@@ -0,0 +1,10 @@
+{-# OPTIONS_GHC -F -pgmF hspec-discover -Wno-missing-export-lists #-}
+
+-- |
+--
+-- Module      : Main
+-- Copyright   : (c) 2025 Patrick Brisbin
+-- License     : AGPL-3
+-- Maintainer  : pbrisbin@gmail.com
+-- Stability   : experimental
+-- Portability : POSIX
