diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,3 +1,10 @@
+0.2.2
+===
+
+- GHC 9.14.1 support
+- removed string-interpolate dependency
+- use ByteString concatenation instead
+
 0.2.1
 ===
 
diff --git a/markup-parse.cabal b/markup-parse.cabal
--- a/markup-parse.cabal
+++ b/markup-parse.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name: markup-parse
-version: 0.2.1.0
+version: 0.2.2.0
 license: BSD-3-Clause
 license-file: LICENSE
 copyright: Copyright, Tony Day, 2023-
@@ -15,10 +15,9 @@
 
 build-type: Simple
 tested-with:
-  ghc ==9.6.7
-  ghc ==9.8.4
-  ghc ==9.10.2
+  ghc ==9.10.3
   ghc ==9.12.2
+  ghc ==9.14.1
 
 extra-doc-files:
   ChangeLog.md
@@ -75,23 +74,11 @@
     containers >=0.6 && <0.9,
     deepseq >=1.4 && <1.6,
     flatparse >=0.3.5 && <0.6,
-    string-interpolate >=0.3 && <0.4,
     these >=1.1 && <1.3,
 
   exposed-modules:
     MarkupParse
     MarkupParse.Internal.FlatParse
-
-test-suite doctests
-  import: ghc2024-stanza
-  main-is: doctests.hs
-  hs-source-dirs: test
-  build-depends:
-    base >=4.14 && <5,
-    doctest-parallel >=0.3 && <0.5,
-
-  ghc-options: -threaded
-  type: exitcode-stdio-1.0
 
 test-suite markup-parse-diff
   import: ghc-options-exe-stanza
diff --git a/src/MarkupParse.hs b/src/MarkupParse.hs
--- a/src/MarkupParse.hs
+++ b/src/MarkupParse.hs
@@ -1,5 +1,4 @@
 {-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE QuasiQuotes #-}
 {-# LANGUAGE TemplateHaskell #-}
 {-# LANGUAGE TypeFamilies #-}
 
@@ -106,7 +105,6 @@
 import Data.List qualified as List
 import Data.Map.Strict qualified as Map
 import Data.Maybe
-import Data.String.Interpolate
 import Data.These
 import Data.Tree
 import FlatParse.Basic hiding (cut, take)
@@ -116,12 +114,10 @@
 
 -- $setup
 -- >>> :set -XTemplateHaskell
--- >>> :set -XQuasiQuotes
 -- >>> :set -XOverloadedStrings
 -- >>> import MarkupParse
 -- >>> import MarkupParse.Internal.FlatParse
 -- >>> import FlatParse.Basic
--- >>> import Data.String.Interpolate
 -- >>> import Data.ByteString.Char8 qualified as B
 -- >>> import Data.Tree
 
@@ -253,7 +249,7 @@
 
 -- | Concatenate sequential content and normalize attributes; unwording class values and removing duplicate attributes (taking last).
 --
--- >>> B.putStr $ warnError $ markdown Compact Xml $ normalize (markup_ Xml [i|<foo class="a" class="b" bar="first" bar="last"/>|])
+-- >>> B.putStr $ warnError $ markdown Compact Xml $ normalize (markup_ Xml "<foo class=\"a\" class=\"b\" bar=\"first\" bar=\"last\"/>")
 -- <foo bar="last" class="a b"/>
 normalize :: Markup -> Markup
 normalize m = normContent $ Markup $ fmap (fmap normTokenAttrs) (elements m)
@@ -303,16 +299,16 @@
 --
 -- Specifically, an 'EndTag' will occur in a list of tokens, but not as a primitive in 'Markup'. It may turn out to be better to have two different types for these two uses and future iterations of this library may head in this direction.
 --
--- >>> runParser_ (many (tokenP Html)) [i|<foo>content</foo>|]
+-- >>> runParser_ (many (tokenP Html)) "<foo>content</foo>"
 -- [OpenTag StartTag "foo" [],Content "content",EndTag "foo"]
 --
--- >>> runParser_ (tokenP Xml) [i|<foo/>|]
+-- >>> runParser_ (tokenP Xml) "<foo/>"
 -- OpenTag EmptyElemTag "foo" []
 --
 -- >>> runParser_ (tokenP Html) "<!-- Comment -->"
 -- Comment " Comment "
 --
--- >>> runParser_ (tokenP Xml) [i|<?xml version="1.0" encoding="UTF-8"?>|]
+-- >>> runParser_ (tokenP Xml) "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
 -- Decl "xml" [Attr {attrName = "version", attrValue = " version=\"1.0\""},Attr {attrName = "encoding", attrValue = "UTF-8"}]
 --
 -- >>> runParser_ (tokenP Html) "<!DOCTYPE html>"
@@ -321,10 +317,10 @@
 -- >>> runParser_ (tokenP Xml) "<!DOCTYPE foo [ declarations ]>"
 -- Doctype "DOCTYPE foo [ declarations ]"
 --
--- >>> runParser (tokenP Html) [i|<foo a="a" b="b" c=c check>|]
+-- >>> runParser (tokenP Html) "<foo a=\"a\" b=\"b\" c=c check>"
 -- OK (OpenTag StartTag "foo" [Attr {attrName = "a", attrValue = "a"},Attr {attrName = "b", attrValue = "b"},Attr {attrName = "c", attrValue = "c"},Attr {attrName = "check", attrValue = ""}]) ""
 --
--- >>> runParser (tokenP Xml) [i|<foo a="a" b="b" c=c check>|]
+-- >>> runParser (tokenP Xml) "<foo a=\"a\" b=\"b\" c=c check>"
 -- Fail
 data Token
   = -- | A tag. https://developer.mozilla.org/en-US/docs/Glossary/Tag
@@ -364,7 +360,7 @@
 --
 -- No attempt is made to meet the <https://en.wikipedia.org/wiki/List_of_XML_and_HTML_character_entity_references HTML Standards>
 --
--- >>> escape [i|<foo class="a" bar='b'>|]
+-- >>> escape "<foo class=\"a\" bar='b'>"
 -- "&lt;foo class=&quot;a&quot; bar=&apos;b&apos;&gt;"
 escape :: ByteString -> ByteString
 escape bs = B.concatMap escapeChar bs
@@ -402,7 +398,7 @@
 
 -- | Parse a bytestring into tokens
 --
--- >>> tokenize Html [i|<foo>content</foo>|]
+-- >>> tokenize Html "<foo>content</foo>"
 -- That [OpenTag StartTag "foo" [],Content "content",EndTag "foo"]
 tokenize :: Standard -> ByteString -> Warn [Token]
 tokenize s bs = first ((: []) . MarkupParser) $ runParserWarn (many (tokenP s)) bs
@@ -474,7 +470,7 @@
 -- Markup {elements = [Node {rootLabel = Content "<content>", subForest = []}]}
 --
 -- >>> markup_ Html $ markdown_ Compact Html $ contentRaw "<content>"
--- Markup {elements = *** Exception: UnclosedTag
+-- *** Exception: UnclosedTag
 -- ...
 contentRaw :: ByteString -> Markup
 contentRaw bs = Markup [pure $ Content bs]
@@ -490,7 +486,7 @@
 -- In parsing, boolean attributes, which are not required to have a value in HTML,
 -- will be set a value of "", which is ok. But this will then be rendered.
 --
--- >>> detokenize Html <$> tokenize_ Html [i|<input checked>|]
+-- >>> detokenize Html <$> tokenize_ Html "<input checked>"
 -- ["<input checked=\"\">"]
 data Attr = Attr {attrName :: !AttrName, attrValue :: !AttrValue}
   deriving (Eq, Ord, Show, Generic, Data)
@@ -531,7 +527,7 @@
 --
 -- Does not attempt to escape double quotes.
 renderAttr :: Attr -> ByteString
-renderAttr (Attr k v) = [i|#{k}="#{v}"|]
+renderAttr (Attr k v) = k <> "=\"" <> v <> "\""
 
 -- | bytestring representation of 'Token'.
 --
@@ -539,18 +535,18 @@
 -- "<foo>"
 detokenize :: Standard -> Token -> ByteString
 detokenize s = \case
-  (OpenTag StartTag n []) -> [i|<#{n}>|]
-  (OpenTag StartTag n as) -> [i|<#{n}#{renderAttrs as}>|]
+  (OpenTag StartTag n []) -> "<" <> n <> ">"
+  (OpenTag StartTag n as) -> "<" <> n <> renderAttrs as <> ">"
   (OpenTag EmptyElemTag n as) ->
     bool
-      [i|<#{n}#{renderAttrs as}/>|]
-      [i|<#{n}#{renderAttrs as} />|]
+      ("<" <> n <> renderAttrs as <> "/>")
+      ("<" <> n <> renderAttrs as <> " />")
       (s == Html)
-  (EndTag n) -> [i|</#{n}>|]
+  (EndTag n) -> "</" <> n <> ">"
   (Content t) -> t
-  (Comment t) -> [i|<!--#{t}-->|]
-  (Doctype t) -> [i|<!#{t}>|]
-  (Decl t as) -> bool [i|<?#{t}#{renderAttrs as}?>|] [i|<!#{t}!>|] (s == Html)
+  (Comment t) -> "<!--" <> t <> "-->"
+  (Doctype t) -> "<!" <> t <> ">"
+  (Decl t as) -> bool ("<?" <> t <> renderAttrs as <> "?>") ("<!" <> t <> "!>") (s == Html)
 
 -- | @Indented 0@ puts newlines in between the tags.
 data RenderStyle = Compact | Indented Int deriving (Eq, Ord, Show, Generic, Data)
@@ -568,14 +564,14 @@
 
 -- | Convert 'Markup' to bytestrings
 --
--- >>> markdown (Indented 4) Html (markup_ Html [i|<foo><br></foo>|])
+-- >>> markdown (Indented 4) Html (markup_ Html "<foo><br></foo>")
 -- That "<foo>\n    <br>\n</foo>"
 markdown :: RenderStyle -> Standard -> Markup -> Warn ByteString
 markdown r s m = second (finalConcat r) $ concatWarns $ foldTree (renderBranch r s) <$> (elements $ normContent m)
 
 -- | Convert 'Markup' to 'ByteString' and error on warnings.
 --
--- >>> B.putStr $ markdown_ (Indented 4) Html (markup_ Html [i|<foo><br></foo>|])
+-- >>> B.putStr $ markdown_ (Indented 4) Html (markup_ Html "<foo><br></foo>")
 -- <foo>
 --     <br>
 -- </foo>
diff --git a/test/doctests.hs b/test/doctests.hs
deleted file mode 100644
--- a/test/doctests.hs
+++ /dev/null
@@ -1,6 +0,0 @@
-import System.Environment (getArgs)
-import Test.DocTest (mainFromCabal)
-import Prelude (IO, (=<<))
-
-main :: IO ()
-main = mainFromCabal "markup-parse" =<< getArgs
