diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for scripths
+
+## 0.1.0.0 -- YYYY-mm-dd
+
+* First version. Released on an unsuspecting world.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2026 DataHaskell
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,99 @@
+# scripths
+
+GHCi scripts for standalone execution and Markdown documentation.
+
+**scripths** lets you write `.ghci` scripts with dependency management and run them as standalone programs — or embed executable Haskell code blocks in Markdown files and evaluate them notebook-style with captured output.
+
+## Features
+
+- **Standalone `.ghci` execution** — Run GHCi scripts directly from the command line, with automatic dependency.
+- **Cabal metadata directives** — Declare `build-depends`, `default-extensions`, and `ghc-options` inline using `-- cabal:` comments.
+- **Markdown notebooks** — Execute Haskell code blocks inside Markdown files and render the output back into the document as block quotes.
+- **Smart GHCi rendering** — Multi-line definitions are automatically wrapped in `:{`/`:}` blocks, and IO binds / Template Haskell splices are handled correctly as individual statements.
+
+## Installation
+
+```bash
+cabal install scripths
+```
+
+## CLI Usage
+
+```
+scripths [--output=<filename>] [-o <filename>] <script>
+```
+
+When `-o` / `--output` is provided for Markdown files, the result is written to that path. Otherwise it is printed to stdout. The file extension determines the mode. `.ghci` / `hs` files are parsed and executed as a standalone GHCi script. `.md` / `.markdown` files are processed as a notebook with captured output.
+
+Requires GHC and cabal-install on your PATH.
+
+## Quick Start
+
+### Running a GHCi script
+
+Create a file `example.ghci`:
+
+```haskell
+double :: Int -> Int
+double = (*2)
+
+-- cabal: build-depends: text
+:set -XOverloadedStrings
+import qualified Data.Text as T
+
+T.take 10 "hello"
+
+double 5
+```
+
+Run it:
+
+```bash
+scripths example.ghci
+```
+
+### Running a Markdown notebook
+
+Create a file `notebook.md`:
+
+````markdown
+# My Analysis
+
+Some introductory prose.
+
+```haskell
+print (5 + 5)
+```
+
+Define some values:
+
+```haskell
+x = 10
+y = 20
+```
+
+We can then add the values in the next block and
+the output is embeded below.
+
+```haskell
+x + y
+```
+````
+
+Run it and write the results to a new file:
+
+```bash
+scripths -o output.md notebook.md
+```
+
+Each Haskell code block is evaluated in order, and its output is inserted into the Markdown as a block quote beneath the code fence.
+
+## Cabal Metadata Directives
+
+You can declare dependencies, language extensions, and GHC options directly inside your scripts using `-- cabal:` comments:
+
+```
+-- cabal: build-depends: text, containers
+-- cabal: default-extensions: OverloadedStrings, TypeApplications
+-- cabal: ghc-options: -Wall
+```
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,48 @@
+module Main where
+
+import Data.List ( isPrefixOf )
+import qualified Data.Text.IO as TIO
+import System.Environment (getArgs, getProgName)
+import System.Exit (exitFailure)
+import System.FilePath (takeExtension)
+
+import ScriptHs.Notebook (runNotebook)
+import ScriptHs.Parser (parseScript)
+import ScriptHs.Run (runScript)
+
+main :: IO ()
+main = do
+    args <- getArgs
+    case args of
+        [path] -> dispatch path Nothing
+        [] -> usage
+        _ -> dispatch (last args) (outputFile (init args))
+
+dispatch :: FilePath -> Maybe FilePath -> IO ()
+dispatch path outputPath =
+    case takeExtension path of
+        ".md" -> runNotebook path outputPath
+        ".markdown" -> runNotebook path outputPath
+        _ -> do
+            contents <- TIO.readFile path
+            let sf = parseScript contents
+            runScript sf
+
+-- TODO: This is kinda brittle but I don't wanna
+-- include the whole opt parser lib just to
+-- get the output file. Maybe there's a cheaper
+-- way to do this.
+outputFile :: [String] -> Maybe FilePath
+outputFile [] = Nothing
+outputFile ("-o" : f : _) = Just f
+outputFile (x : xs) =
+    if "--output=" `isPrefixOf` x
+        then Just (drop (length "--output=") x)
+        else outputFile (tail xs)
+
+usage :: IO ()
+usage = do
+    prog <- getProgName
+    putStrLn $
+        "Usage: " ++ prog ++ " [--output=<filename>] [-o <filename>] <script>"
+    exitFailure
diff --git a/scripths.cabal b/scripths.cabal
new file mode 100644
--- /dev/null
+++ b/scripths.cabal
@@ -0,0 +1,69 @@
+cabal-version:      3.0
+name:               scripths
+version:            0.1.0.0
+synopsis:           GHCi scripts for standalone execution and Markdown documentation.
+description:        GHCi scripts for standalone execution (with dependency resolution) and Markdown documentation (produces inline output).
+homepage:           https://www.datahaskell.org/
+license:            MIT
+license-file:       LICENSE
+author:             DataHaskell
+maintainer:         mschavinda@gmail.com
+category:           Data
+build-type:         Simple
+extra-doc-files:    CHANGELOG.md, README.md
+tested-with:        GHC ==9.6.7
+bug-reports:        https://github.com/DataHaskell/scripths/issues
+
+source-repository head
+  type:     git
+  location: https://github.com/DataHaskell/scripths
+
+common warnings
+    ghc-options: -Wall
+
+library
+    import:           warnings
+    exposed-modules:  ScriptHs.Parser,
+                      ScriptHs.Render,
+                      ScriptHs.Markdown,
+                      ScriptHs.Notebook,
+                      ScriptHs.Run
+    build-depends:    base >= 4 && < 5,
+                      filepath >= 1.4 && < 1.6,
+                      process >= 1.6 && < 1.8,
+                      temporary >= 1.3 && < 1.4,
+                      text >= 2.0 && < 3
+    hs-source-dirs:   src
+    default-language: Haskell2010
+    default-extensions: OverloadedStrings
+
+executable scripths
+    import:           warnings
+    main-is:          Main.hs
+    build-depends:
+        base >= 4 && < 5,
+        scripths,
+        filepath >= 1.4 && < 1.6,
+        text >= 2.0 && < 3,
+
+    hs-source-dirs:   app
+    default-language: Haskell2010
+
+test-suite scripths-test
+    import:           warnings
+    default-language: Haskell2010
+    type:             exitcode-stdio-1.0
+    hs-source-dirs:   test
+    main-is:          Main.hs
+    other-modules:    Test.Parser,
+                      Test.Render,
+                      Test.Markdown,
+                      Test.Notebook
+    build-depends:
+        base >= 4 && < 5,
+        scripths,
+        text >= 1.2  && < 2.2,
+        tasty >= 1.4  && < 1.6,
+        tasty-hunit  >= 0.10 && < 0.11
+    default-extensions: OverloadedStrings
+    ghc-options:        -Wall
diff --git a/src/ScriptHs/Markdown.hs b/src/ScriptHs/Markdown.hs
new file mode 100644
--- /dev/null
+++ b/src/ScriptHs/Markdown.hs
@@ -0,0 +1,58 @@
+module ScriptHs.Markdown (
+    Segment (..),
+    parseMarkdown,
+    reassemble,
+) where
+
+import Data.Text (Text)
+import qualified Data.Text as T
+
+data Segment
+    = Prose Text
+    | CodeBlock Text Text (Maybe Text)
+    deriving (Show, Eq)
+
+parseMarkdown :: Text -> [Segment]
+parseMarkdown = parseMarkdown' [] . T.lines
+
+parseMarkdown' :: [Text] -> [Text] -> [Segment]
+parseMarkdown' acc [] = [Prose prose | not (T.null prose)]
+  where
+    prose = T.unlines acc
+parseMarkdown' acc (line : rest) = case T.strip <$> T.stripPrefix fence line of
+    Nothing -> parseMarkdown' (acc ++ [line]) rest
+    Just lang ->
+        let
+            prose = T.unlines acc
+            (codeLines, rest') = fmap (drop 1) (break ((== fence) . T.strip) rest)
+            segments =
+                [Prose prose | not (T.null prose)]
+                    ++ [CodeBlock lang (T.unlines codeLines) Nothing]
+         in
+            segments ++ parseMarkdown' [] rest'
+
+fence :: Text
+fence = "```"
+
+fenceCodeSegment :: Text -> Text -> Text
+fenceCodeSegment lang output = "\n" <> fence <> lang <> "\n" <> output <> fence <> "\n"
+
+reassemble :: [Segment] -> Text
+reassemble = T.concat . map renderSegment
+
+renderSegment :: Segment -> Text
+renderSegment (Prose t) = t
+renderSegment (CodeBlock lang code Nothing) = fenceCodeSegment lang code
+renderSegment (CodeBlock lang code (Just output)) =
+    if T.null (T.strip output)
+        then fenceCodeSegment lang code
+        else fenceCodeSegment lang code <> blockQuote output
+
+blockQuote :: Text -> Text
+blockQuote t =
+    let
+        ls = T.lines t
+        trimmed = reverse $ dropWhile T.null $ reverse ls
+        quoted = T.unlines $ map (\l -> if T.null l then ">" else "> " <> l) trimmed
+     in
+        quoted <> "\n"
diff --git a/src/ScriptHs/Notebook.hs b/src/ScriptHs/Notebook.hs
new file mode 100644
--- /dev/null
+++ b/src/ScriptHs/Notebook.hs
@@ -0,0 +1,90 @@
+module ScriptHs.Notebook where
+
+import Data.Bifunctor (Bifunctor (second))
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Text.IO as TIO
+
+import ScriptHs.Markdown (Segment (..), parseMarkdown, reassemble)
+import ScriptHs.Parser (
+    CabalMeta (..),
+    Line (..),
+    ScriptFile (..),
+    mergeMetas,
+    parseScript,
+ )
+import ScriptHs.Run (runScriptCapture)
+
+type IndexedSegments = [(Int, Segment)]
+type IndexedBlocks = [(Int, [Line])]
+
+runNotebook :: FilePath -> Maybe FilePath -> IO ()
+runNotebook path outputPath = do
+    contents <- TIO.readFile path
+    outputMd <- processNotebook contents
+    case outputPath of
+        Nothing -> TIO.putStr outputMd
+        Just output -> TIO.writeFile output outputMd
+
+processNotebook :: Text -> IO Text
+processNotebook contents = do
+    let indexedSegments = zip [0 ..] (parseMarkdown contents)
+        (metas, indexedCodeBlocks) = parseBlocks indexedSegments
+    if null indexedCodeBlocks
+        then pure contents
+        else executeCodeCells metas indexedSegments indexedCodeBlocks
+
+executeCodeCells :: CabalMeta -> IndexedSegments -> IndexedBlocks -> IO Text
+executeCodeCells meta allSegments codeBlocks = do
+    let ghciScript = generatedMarkedScript codeBlocks
+        sf = ScriptFile{scriptMeta = meta, scriptLines = ghciScript}
+    rawOutput <- runScriptCapture sf
+    let outputs = splitByMarkers rawOutput (map fst codeBlocks)
+        blocksWithOutput = addOutputToSegments outputs allSegments
+    pure $ reassemble blocksWithOutput
+
+addOutputToSegments :: [(Int, Text)] -> IndexedSegments -> [Segment]
+addOutputToSegments outputs = map addOutput
+  where
+    addOutput :: (Int, Segment) -> Segment
+    addOutput (i, CodeBlock lang code _) = CodeBlock lang code (lookup i outputs)
+    addOutput (_, seg) = seg
+
+mkIndexedCodeSegments :: IndexedSegments -> IndexedSegments
+mkIndexedCodeSegments segments = [(i, c) | (i, c@(CodeBlock lang _ _)) <- segments, isHaskell lang]
+
+parseBlocks :: IndexedSegments -> (CabalMeta, IndexedBlocks)
+parseBlocks blocks = (metas, map (second scriptLines) sfs)
+  where
+    sfs =
+        [(i, parseScript code) | (i, CodeBlock lang code _) <- blocks, isHaskell lang]
+    metas = mergeMetas (map (scriptMeta . snd) sfs)
+
+generatedMarkedScript :: IndexedBlocks -> [Line]
+generatedMarkedScript = concatMap renderWithMarker
+  where
+    renderWithMarker :: (Int, [Line]) -> [Line]
+    renderWithMarker (idx, ls) =
+        ls
+            ++ [ Blank
+               , HaskellLine ("putStrLn " <> T.pack (show (T.unpack (mkMarker idx))))
+               , Blank
+               ]
+
+splitByMarkers :: Text -> [Int] -> [(Int, Text)]
+splitByMarkers _ [] = []
+splitByMarkers remaining (idx : rest) =
+    let marker = mkMarker idx
+        (before, after) = T.breakOn marker remaining
+     in if T.null after
+            then [(idx, T.strip before)]
+            else
+                (idx, T.strip before) : splitByMarkers (T.drop (T.length marker) after) rest
+
+-- A marker we'll print to GHCi output to
+-- denote the end of a cell execution block.
+mkMarker :: Int -> Text
+mkMarker n = "---SCRIPTHS_BLOCK_" <> T.pack (show n) <> "_END---"
+
+isHaskell :: Text -> Bool
+isHaskell lang = T.toLower (T.strip lang) `elem` ["haskell", "hs"]
diff --git a/src/ScriptHs/Parser.hs b/src/ScriptHs/Parser.hs
new file mode 100644
--- /dev/null
+++ b/src/ScriptHs/Parser.hs
@@ -0,0 +1,173 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Parsing for @.ghci@ scripts understood by ScriptHs.
+
+A script is a sequence of lines, optionally preceded by cabal metadata
+directives that declare dependencies, language extensions, and GHC options.
+
+Example script:
+
+@
+-- cabal: build-depends: containers
+-- cabal: default-extensions: OverloadedStrings
+import qualified Data.Map as M
+
+M.fromList (zip [1..10] [2,4..])
+@
+-}
+module ScriptHs.Parser (
+    ScriptFile (..),
+    CabalMeta (..),
+    Line (..),
+    parseScript,
+    mergeMetas,
+) where
+
+import Data.Text (Text)
+import qualified Data.Text as T
+
+{- | A fully parsed script, consisting of aggregated cabal metadata and an
+ordered list of code lines.
+-}
+data ScriptFile = ScriptFile
+    { scriptMeta :: CabalMeta
+    -- ^ Aggregated metadata from all @-- cabal:@ directives in the script.
+    , scriptLines :: [Line]
+    -- ^ The code lines of the script, in order, with metadata lines removed.
+    }
+    deriving (Show, Eq)
+
+{- | Cabal metadata extracted from @-- cabal:@ directives.
+
+Multiple directives of the same kind are merged:
+
+@
+-- cabal: build-depends: text
+-- cabal: build-depends: containers
+@
+
+produces @CabalMeta { metaDeps = [\"text\", \"containers\"], ... }@.
+-}
+data CabalMeta = CabalMeta
+    { metaDeps :: [Text]
+    -- ^ Packages from @build-depends@ directives.
+    , metaExts :: [Text]
+    -- ^ Extensions from @default-extensions@ directives.
+    , metaGhcOptions :: [Text]
+    -- ^ Flags from @ghc-options@ directives.
+    }
+    deriving (Show, Eq)
+
+-- | A single logical line from the script body.
+data Line
+    = -- | An empty or whitespace-only line.
+      Blank
+    | -- | A GHCi directive, e.g. @:set -XOverloadedStrings@ or a @:{@ \/ @:}@ block.
+      GhciCommand Text
+    | -- | A GHC language pragma, e.g. @{\-# LANGUAGE OverloadedStrings #-\}@.
+      Pragma Text
+    | -- | A Haskell import declaration, e.g. @import Data.Text (Text)@.
+      Import Text
+    | -- | Any other Haskell source line.
+      HaskellLine Text
+    deriving (Show, Eq)
+
+{- | Parse a ScriptHs script from 'Text'.
+
+Returns a 'ScriptFile' on success, or a human-readable error message on
+failure.
+
+Example:
+
+>>> parseScript "-- cabal: build-depends: text\nimport Data.Text (Text)\n"
+Right (ScriptFile {scriptMeta = CabalMeta {metaDeps = ["text"], metaExts = [], metaGhcOptions = []}, scriptLines = [Import "import Data.Text (Text)"]})
+
+>>> parseScript ""
+Right (ScriptFile {scriptMeta = CabalMeta {metaDeps = [], metaExts = [], metaGhcOptions = []}, scriptLines = []})
+-}
+parseScript :: Text -> ScriptFile
+parseScript input =
+    let textLines = T.lines input
+        parsedLines = map parseLine textLines
+        (metas, code) = partitionLines parsedLines
+        meta = mergeMetas metas
+     in ScriptFile{scriptMeta = meta, scriptLines = code}
+
+data RawLine
+    = RawCabalMeta CabalMeta
+    | RawCode Line
+
+partitionLines :: [RawLine] -> ([CabalMeta], [Line])
+partitionLines = foldr go ([], [])
+  where
+    go (RawCabalMeta m) (ms, cs) = (m : ms, cs)
+    go (RawCode c) (ms, cs) = (ms, c : cs)
+
+mergeMetas :: [CabalMeta] -> CabalMeta
+mergeMetas ms =
+    CabalMeta
+        { metaDeps = concatMap metaDeps ms
+        , metaExts = concatMap metaExts ms
+        , metaGhcOptions = concatMap metaGhcOptions ms
+        }
+
+parseLine :: Text -> RawLine
+parseLine line
+    | Just meta <- parseCabalMeta line = RawCabalMeta meta
+    | otherwise = RawCode (parseCodeLine line)
+
+parseCabalMeta :: Text -> Maybe CabalMeta
+parseCabalMeta line = do
+    rest <- T.stripPrefix "-- cabal:" line
+    let rest' = T.stripStart rest
+    case T.break (== ':') rest' of
+        (key, colonAndValue) | not (T.null colonAndValue) -> do
+            let value = T.stripStart (T.drop 1 colonAndValue)
+                items = map T.strip (T.splitOn "," value)
+            pure $ case T.strip key of
+                "build-depends" -> emptyCabal{metaDeps = items}
+                "default-extensions" -> emptyCabal{metaExts = items}
+                "ghc-options" -> emptyCabal{metaGhcOptions = items}
+                _ -> emptyCabal
+        _ -> Nothing
+  where
+    emptyCabal = CabalMeta{metaDeps = [], metaExts = [], metaGhcOptions = []}
+
+parseCodeLine :: Text -> Line
+parseCodeLine line
+    | isBlankLine line = Blank
+    | Just cmd <- parseGhciCommand line = GhciCommand cmd
+    | Just pragma <- parsePragma line = Pragma pragma
+    | Just imp <- parseImport line = Import imp
+    | otherwise = HaskellLine (rewriteSplice line)
+
+isBlankLine :: Text -> Bool
+isBlankLine = T.null . T.strip
+
+parseGhciCommand :: Text -> Maybe Text
+parseGhciCommand line =
+    let stripped = T.stripStart line
+     in case T.uncons stripped of
+            Just (':', rest) -> Just (":" <> rest)
+            _ -> Nothing
+
+parsePragma :: Text -> Maybe Text
+parsePragma line =
+    if "{-#" `T.isPrefixOf` line
+        then Just line
+        else Nothing
+
+parseImport :: Text -> Maybe Text
+parseImport line =
+    case T.stripPrefix "import" line of
+        Just rest
+            | not (T.null rest) && (T.head rest == ' ' || T.head rest == '\t') ->
+                Just line
+        _ -> Nothing
+
+-- https://discourse.haskell.org/t/injecting-variables-into-a-ghci-session/12558/2?u=mchav
+rewriteSplice :: Text -> Text
+rewriteSplice line =
+    case T.stripPrefix "$(" line >>= T.stripSuffix ")" of
+        Just inner -> "_ = (); " <> T.strip inner
+        Nothing -> line
diff --git a/src/ScriptHs/Render.hs b/src/ScriptHs/Render.hs
new file mode 100644
--- /dev/null
+++ b/src/ScriptHs/Render.hs
@@ -0,0 +1,130 @@
+{- | Rendering 'ScriptHs.Parser.Line' sequences into GHCi-compatible scripts.
+
+GHCi imposes constraints that raw Haskell source does not: multi-line
+definitions must be wrapped in @:{@ \/ @:}@ blocks, and monadic binds
+(@<-@) or Template Haskell splices (@$(@) must be issued as individual
+statements rather than grouped with pure definitions. 'toGhciScript'
+handles all of this automatically
+-}
+module ScriptHs.Render (
+    toGhciScript,
+) where
+
+import Data.Text (Text)
+import qualified Data.Text as T
+import ScriptHs.Parser (Line (..))
+
+data Block
+    = SingleLine Line
+    | MultiLine [Line]
+    deriving (Show, Eq)
+
+{- | Render a list of 'Line's as a GHCi script.
+
+Lines are grouped into blocks and wrapped in @:{@ \/ @:}@ where necessary.
+Monadic bind expressions (@<-@) and Template Haskell splices (@$(@) are
+always emitted as individual GHCi statements, since GHCi does not allow
+them inside multi-line blocks.
+
+Example — a multi-line definition is wrapped in a single block:
+
+@
+toGhciScript
+  [ HaskellLine "double :: Int -> Int"
+  , HaskellLine "double = (*2)"
+  ]
+-- :{
+-- double :: Int -> Int
+-- double = (*2)
+-- :}
+@
+
+Example — an IO bind is kept as a standalone statement:
+
+@
+toGhciScript
+  [ HaskellLine "x <- getLine"
+  , HaskellLine "putStrLn x"
+  ]
+-- x <- getLine
+-- putStrLn x
+@
+-}
+toGhciScript :: [Line] -> Text
+toGhciScript = T.unlines . concatMap renderBlock . groupBlocks
+
+groupBlocks :: [Line] -> [Block]
+groupBlocks = concatMap splitIOBinds . groupRaw
+
+groupRaw :: [Line] -> [Block]
+groupRaw [] = []
+groupRaw (Blank : rest) = SingleLine Blank : groupRaw rest
+groupRaw (GhciCommand t : rest) = SingleLine (GhciCommand t) : groupRaw rest
+groupRaw ls =
+    let (block, rest) = span isBlockLine ls
+        (block', rest') = takeIfIndented block rest
+     in classifyBlock block' : groupRaw rest'
+
+takeIfIndented :: [Line] -> [Line] -> ([Line], [Line])
+takeIfIndented block rest = (block ++ takeWhile isIndented rest, dropWhile isIndented rest)
+
+isIndented :: Line -> Bool
+isIndented Blank = True
+isIndented (HaskellLine t) = T.isPrefixOf " " t || T.isPrefixOf "\t" t
+isIndented _ = False
+
+splitIOBinds :: Block -> [Block]
+splitIOBinds (MultiLine ls) = map classifyBlock (splitOn isIOLine ls)
+  where
+    isIOLine l = isIOorTH (lineText l)
+splitIOBinds b = [b]
+
+splitOn :: (a -> Bool) -> [a] -> [[a]]
+splitOn _ [] = []
+splitOn p (x : xs)
+    | p x = [x] : splitOn p xs
+    | otherwise =
+        let (run, rest) = break p xs
+         in (x : run) : splitOn p rest
+
+isBlockLine :: Line -> Bool
+isBlockLine Blank = False
+isBlockLine (GhciCommand _) = False
+isBlockLine _ = True
+
+classifyBlock :: [Line] -> Block
+classifyBlock [l] = SingleLine l
+classifyBlock ls = MultiLine ls
+
+renderBlock :: Block -> [Text]
+renderBlock (SingleLine Blank) = [""]
+renderBlock (SingleLine (GhciCommand t)) = [t]
+renderBlock (SingleLine (Pragma t)) = [t]
+renderBlock (SingleLine (Import t)) = [t]
+renderBlock (SingleLine (HaskellLine t))
+    | isIOorTH t = wrapMulti [t]
+    | otherwise = [t]
+renderBlock (MultiLine ls)
+    | allIOorTH ls = concatMap (\l -> wrapMulti [lineText l]) ls
+    | otherwise = wrapMulti (map lineText ls)
+
+wrapMulti :: [Text] -> [Text]
+wrapMulti ls = [":{"] ++ ls ++ [":}"]
+
+lineText :: Line -> Text
+lineText Blank = ""
+lineText (GhciCommand t) = t
+lineText (Pragma t) = t
+lineText (Import t) = t
+lineText (HaskellLine t) = t
+
+isIOorTH :: Text -> Bool
+isIOorTH t =
+    not (T.isPrefixOf " " t)
+        && ( T.isInfixOf "<-" t
+                || T.isInfixOf "$(" t
+                || T.isPrefixOf "_ = ();" (T.stripStart t)
+           )
+
+allIOorTH :: [Line] -> Bool
+allIOorTH = all (isIOorTH . lineText)
diff --git a/src/ScriptHs/Run.hs b/src/ScriptHs/Run.hs
new file mode 100644
--- /dev/null
+++ b/src/ScriptHs/Run.hs
@@ -0,0 +1,87 @@
+{-# LANGUAGE NamedFieldPuns #-}
+
+module ScriptHs.Run where
+
+import qualified Data.Text as T
+import qualified Data.Text.IO as TIO
+import ScriptHs.Parser (
+    CabalMeta (..),
+    ScriptFile (scriptLines, scriptMeta),
+ )
+import ScriptHs.Render (toGhciScript)
+import System.Exit (ExitCode (..), exitWith)
+import System.FilePath ((</>))
+import System.IO (stderr)
+import System.IO.Temp (withSystemTempDirectory)
+import System.Process (
+    CreateProcess (delegate_ctlc),
+    createProcess,
+    proc,
+    readProcessWithExitCode,
+    waitForProcess,
+ )
+
+runScript :: ScriptFile -> IO ()
+runScript = runWithCont runGhc
+
+runScriptCapture :: ScriptFile -> IO T.Text
+runScriptCapture = runWithCont captureGhc
+
+runWithCont :: (FilePath -> CabalMeta -> FilePath -> IO a) -> ScriptFile -> IO a
+runWithCont cont sf = withSystemTempDirectory "scripths" $ \tmpDir -> do
+    (ghciPath, envPath) <- createScriptAndEnvironment sf tmpDir
+    cont envPath (scriptMeta sf) ghciPath
+
+createScriptAndEnvironment :: ScriptFile -> FilePath -> IO (FilePath, FilePath)
+createScriptAndEnvironment sf tmpDir = do
+    let ghciPath = tmpDir </> "script.ghci"
+        envPath = tmpDir </> ".ghc.environment"
+        sm = scriptMeta sf
+    TIO.writeFile ghciPath (toGhciScript (scriptLines sf))
+    resolveDeps envPath (metaDeps sm <> ["base"])
+    pure (ghciPath, envPath)
+
+captureGhc :: FilePath -> CabalMeta -> FilePath -> IO T.Text
+captureGhc env meta ghciPath = do
+    let args = ghcArgs env meta ghciPath
+    (code, out, err) <- readProcessWithExitCode "ghc" args ""
+    case code of
+        ExitSuccess -> pure (T.pack $ out <> err)
+        ExitFailure _ -> do
+            TIO.hPutStrLn stderr (T.pack err)
+            exitWith code
+
+runGhc :: FilePath -> CabalMeta -> FilePath -> IO ()
+runGhc env meta ghciPath = do
+    let args = ghcArgs env meta ghciPath
+        cp = (proc "ghc" args){delegate_ctlc = True}
+    (_, _, _, ph) <- createProcess cp
+    code <- waitForProcess ph
+    case code of
+        ExitSuccess -> pure ()
+        ExitFailure _ -> exitWith code
+
+ghcArgs :: FilePath -> CabalMeta -> FilePath -> [String]
+ghcArgs env CabalMeta{metaExts, metaGhcOptions} ghciPath =
+    let envFlags = ["-package-env=" ++ env]
+        extFlags = map (\e -> "-X" ++ T.unpack e) metaExts
+        optFlags = map T.unpack metaGhcOptions
+        scriptArg = ":script " ++ ghciPath
+     in envFlags ++ extFlags ++ optFlags ++ ["-e", scriptArg]
+
+resolveDeps :: FilePath -> [T.Text] -> IO ()
+resolveDeps _ [] = pure ()
+resolveDeps envPath deps = do
+    let args =
+            ["-v0", "install", "--lib", "--package-env=" ++ envPath, "--force-reinstalls"]
+                ++ map T.unpack deps
+        cp = (proc "cabal" args){delegate_ctlc = True}
+    (_, _, _, ph) <- createProcess cp
+    code <- waitForProcess ph
+    case code of
+        ExitSuccess -> pure ()
+        ExitFailure n -> do
+            TIO.hPutStrLn
+                stderr
+                (T.pack ("scripths: cabal install --lib failed (exit " ++ show n ++ ")"))
+            exitWith code
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,19 @@
+module Main (main) where
+
+import Test.Tasty (defaultMain, testGroup)
+
+import Test.Markdown (markdownTests)
+import Test.Notebook (notebookTests)
+import Test.Parser (parseTests)
+import Test.Render (renderTests)
+
+main :: IO ()
+main =
+    defaultMain $
+        testGroup
+            "ScriptHs"
+            [ parseTests
+            , renderTests
+            , markdownTests
+            , notebookTests
+            ]
diff --git a/test/Test/Markdown.hs b/test/Test/Markdown.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/Markdown.hs
@@ -0,0 +1,195 @@
+module Test.Markdown (markdownTests) where
+
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (
+    assertBool,
+    assertFailure,
+    testCase,
+    (@?=),
+ )
+
+import Data.Text (Text)
+import qualified Data.Text as T
+import ScriptHs.Markdown (
+    Segment (CodeBlock, Prose),
+    parseMarkdown,
+    reassemble,
+ )
+
+markdownTests :: TestTree
+markdownTests =
+    testGroup
+        "Markdown"
+        [ testGroup
+            "parseMarkdown"
+            [ testCase "prose only" $ do
+                let segs = parseMarkdown "Hello world\nSecond line\n"
+                length segs @?= 1
+                case segs of
+                    [Prose _] -> pure ()
+                    other -> assertFailure $ "expected [Prose], got: " ++ show other
+            , testCase "single code block" $ do
+                let input =
+                        T.unlines
+                            [ "```haskell"
+                            , "print 42"
+                            , "```"
+                            ]
+                let segs = parseMarkdown input
+                length segs @?= 1
+                case segs of
+                    [CodeBlock lang code Nothing] -> do
+                        lang @?= "haskell"
+                        assertBool "has print 42" (T.isInfixOf "print 42" code)
+                    other -> assertFailure $ "expected [CodeBlock], got: " ++ show other
+            , testCase "prose then code" $ do
+                let input =
+                        T.unlines
+                            [ "# Title"
+                            , ""
+                            , "Some text."
+                            , ""
+                            , "```haskell"
+                            , "print 42"
+                            , "```"
+                            ]
+                let segs = parseMarkdown input
+                length segs @?= 2
+                case segs of
+                    [Prose _, CodeBlock "haskell" _ _] -> pure ()
+                    other -> assertFailure $ "expected [Prose, CodeBlock], got: " ++ show other
+            , testCase "code then prose" $ do
+                let input =
+                        T.unlines
+                            [ "```haskell"
+                            , "print 42"
+                            , "```"
+                            , ""
+                            , "Some text after."
+                            ]
+                let segs = parseMarkdown input
+                length segs @?= 2
+                case segs of
+                    [CodeBlock{}, Prose _] -> pure ()
+                    other -> assertFailure $ "expected [CodeBlock, Prose], got: " ++ show other
+            , testCase "multiple code blocks" $ do
+                let input =
+                        T.unlines
+                            [ "# Title"
+                            , ""
+                            , "```haskell"
+                            , "print 1"
+                            , "```"
+                            , ""
+                            , "Middle text."
+                            , ""
+                            , "```haskell"
+                            , "print 2"
+                            , "```"
+                            ]
+                let segs = parseMarkdown input
+                let codeBlocks = [c | c@(CodeBlock{}) <- segs]
+                length codeBlocks @?= 2
+            , testCase "non-haskell code block preserved" $ do
+                let input =
+                        T.unlines
+                            [ "```python"
+                            , "print('hello')"
+                            , "```"
+                            ]
+                let segs = parseMarkdown input
+                case segs of
+                    [CodeBlock "python" code Nothing] ->
+                        assertBool "has python code" (T.isInfixOf "print('hello')" code)
+                    other -> assertFailure $ "expected [CodeBlock python], got: " ++ show other
+            , testCase "empty code block" $ do
+                let input =
+                        T.unlines
+                            [ "```haskell"
+                            , "```"
+                            ]
+                let segs = parseMarkdown input
+                case segs of
+                    [CodeBlock "haskell" code _] ->
+                        assertBool "empty or whitespace" (T.null (T.strip code))
+                    other -> assertFailure $ "expected [CodeBlock], got: " ++ show other
+            , testCase "multi-line code block" $ do
+                let input =
+                        T.unlines
+                            [ "```haskell"
+                            , "import Data.Text"
+                            , ""
+                            , "x <- getLine"
+                            , "print x"
+                            , "```"
+                            ]
+                let segs = parseMarkdown input
+                case segs of
+                    [CodeBlock "haskell" code _] -> do
+                        assertBool "has import" (T.isInfixOf "import Data.Text" code)
+                        assertBool "has x <- getLine" (T.isInfixOf "x <- getLine" code)
+                        assertBool "has print x" (T.isInfixOf "print x" code)
+                    other -> assertFailure $ "expected [CodeBlock], got: " ++ show other
+            ]
+        , testGroup
+            "reassemble"
+            [ testCase "prose without output" $ do
+                let result = reassemble [Prose "Hello\n"]
+                result @?= "Hello\n"
+            , testCase "code block without output" $ do
+                let result = reassemble [CodeBlock "haskell" "print 42\n" Nothing]
+                assertBool "has fence" (T.isInfixOf "```haskell" result)
+                assertBool "has code" (T.isInfixOf "print 42" result)
+                assertBool "no blockquote" (not $ T.isInfixOf "> " result)
+            , testCase "code block with output" $ do
+                let result = reassemble [CodeBlock "haskell" "print 42\n" (Just "42")]
+                assertBool "has fence" (T.isInfixOf "```haskell" result)
+                assertBool "has code" (T.isInfixOf "print 42" result)
+                assertBool "has blockquote" (T.isInfixOf "> 42" result)
+            , testCase "code block with multi-line output" $ do
+                let result = reassemble [CodeBlock "hs" "print [1,2]\n" (Just "1\n2")]
+                assertBool "has > 1" (T.isInfixOf "> 1" result)
+                assertBool "has > 2" (T.isInfixOf "> 2" result)
+            , testCase "empty output is omitted" $ do
+                let result = reassemble [CodeBlock "haskell" "import X\n" (Just "")]
+                assertBool "no blockquote" (not $ T.isInfixOf "> " result)
+            , testCase "whitespace-only output is omitted" $ do
+                let result = reassemble [CodeBlock "haskell" "import X\n" (Just "  \n  \n")]
+                assertBool "no blockquote" (not $ T.isInfixOf "> " result)
+            , testCase "full document roundtrip" $ do
+                let segs =
+                        [ Prose "# Title\n\n"
+                        , CodeBlock "haskell" "print 42\n" (Just "42")
+                        , Prose "\nSome text.\n"
+                        , CodeBlock "haskell" "print 99\n" (Just "99")
+                        ]
+                let result = reassemble segs
+
+                assertBool "has title" (T.isInfixOf "# Title" result)
+                assertBool "has first output" (T.isInfixOf "> 42" result)
+                assertBool "has middle text" (T.isInfixOf "Some text." result)
+                assertBool "has second output" (T.isInfixOf "> 99" result)
+
+                let titleIdx = indexOf "# Title" result
+                    code1Idx = indexOf "print 42" result
+                    middleIdx = indexOf "Some text." result
+                    code2Idx = indexOf "print 99" result
+                assertBool "title before code1" (titleIdx < code1Idx)
+                assertBool "code1 before middle" (code1Idx < middleIdx)
+                assertBool "middle before code2" (middleIdx < code2Idx)
+            ]
+        , testGroup
+            "blockquote"
+            [ testCase "single line" $ do
+                let result = reassemble [CodeBlock "hs" "x\n" (Just "hello")]
+                assertBool "blockquoted" (T.isInfixOf "> hello" result)
+            , testCase "empty lines in output get bare >" $ do
+                let result = reassemble [CodeBlock "hs" "x\n" (Just "a\n\nb")]
+                assertBool "has bare >" (T.isInfixOf "\n>\n" result)
+            ]
+        ]
+
+indexOf :: Text -> Text -> Int
+indexOf needle haystack =
+    let (before, _) = T.breakOn needle haystack
+     in T.length before
diff --git a/test/Test/Notebook.hs b/test/Test/Notebook.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/Notebook.hs
@@ -0,0 +1,203 @@
+module Test.Notebook (notebookTests) where
+
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
+
+import qualified Data.Text as T
+
+import ScriptHs.Markdown (Segment (..))
+import ScriptHs.Notebook (
+    addOutputToSegments,
+    generatedMarkedScript,
+    isHaskell,
+    mkIndexedCodeSegments,
+    mkMarker,
+    parseBlocks,
+    processNotebook,
+    splitByMarkers,
+ )
+
+import ScriptHs.Parser (CabalMeta (metaDeps), Line (..))
+
+notebookTests :: TestTree
+notebookTests =
+    testGroup
+        "Notebook"
+        [ testGroup
+            "isHaskell"
+            [ testCase "accepts haskell" $ do
+                isHaskell "haskell" @?= True
+            , testCase "accepts hs" $ do
+                isHaskell "hs" @?= True
+            , testCase "case-insensitive + trims" $ do
+                isHaskell "  HaSkElL  " @?= True
+                isHaskell "\tHS\n" @?= True
+            , testCase "rejects other languages" $ do
+                isHaskell "python" @?= False
+                isHaskell "" @?= False
+                isHaskell "hask" @?= False
+            ]
+        , testGroup
+            "mkMarker"
+            [ testCase "format includes index" $ do
+                mkMarker 0 @?= "---SCRIPTHS_BLOCK_0_END---"
+                mkMarker 12 @?= "---SCRIPTHS_BLOCK_12_END---"
+            ]
+        , testGroup
+            "splitByMarkers"
+            [ testCase "empty marker list => []" $ do
+                splitByMarkers "anything" [] @?= []
+            , testCase "marker not found => strip remaining as output for that idx" $ do
+                let out = "  hello world  \n"
+                splitByMarkers out [0] @?= [(0, "hello world")]
+            , testCase "single marker splits before it" $ do
+                let out = T.unlines ["42", mkMarker 0, "ignored trailing"]
+                splitByMarkers out [0] @?= [(0, "42")]
+            , testCase "multiple markers split sequentially" $ do
+                let out =
+                        T.concat
+                            [ "cell0\n"
+                            , mkMarker 0
+                            , "\ncell1\n"
+                            , mkMarker 1
+                            , "\ncell2\n"
+                            , mkMarker 2
+                            , "\n"
+                            ]
+                splitByMarkers out [0, 1, 2]
+                    @?= [ (0, "cell0")
+                        , (1, "cell1")
+                        , (2, "cell2")
+                        ]
+            , testCase "later marker missing => remainder becomes that cell output" $ do
+                let out =
+                        T.concat
+                            [ "cell0\n"
+                            , mkMarker 0
+                            , "\ncell1-no-marker-at-end\n"
+                            ]
+                splitByMarkers out [0, 1]
+                    @?= [ (0, "cell0")
+                        , (1, "cell1-no-marker-at-end")
+                        ]
+            ]
+        , testGroup
+            "mkIndexedCodeSegments"
+            [ testCase "filters only haskell/hs code blocks" $ do
+                let segs =
+                        [ (0, Prose "intro\n")
+                        , (1, CodeBlock "haskell" "print 1\n" Nothing)
+                        , (2, CodeBlock "python" "print('x')\n" Nothing)
+                        , (3, CodeBlock "HS" "print 2\n" Nothing)
+                        , (4, Prose "outro\n")
+                        ]
+                let hsSegs = mkIndexedCodeSegments segs
+                map fst hsSegs @?= [1, 3]
+                case map snd hsSegs of
+                    [CodeBlock "haskell" _ _, CodeBlock "HS" _ _] -> pure ()
+                    other -> assertFailure $ "unexpected segments: " ++ show other
+            ]
+        , testGroup
+            "addOutputToSegments"
+            [ testCase "adds outputs to matching code blocks and leaves others alone" $ do
+                let outputs =
+                        [ (1, "out-1")
+                        , (3, "out-3")
+                        , (999, "unused")
+                        ]
+
+                let indexedSegs =
+                        [ (0, Prose "intro\n")
+                        , (1, CodeBlock "haskell" "print 1\n" Nothing)
+                        , (2, CodeBlock "python" "print('x')\n" Nothing)
+                        , (3, CodeBlock "hs" "print 3\n" (Just "old-should-be-replaced"))
+                        , (4, Prose "outro\n")
+                        ]
+
+                let result = addOutputToSegments outputs indexedSegs
+
+                length result @?= length indexedSegs
+
+                head result @?= Prose "intro\n"
+                result !! 4 @?= Prose "outro\n"
+
+                result !! 1 @?= CodeBlock "haskell" "print 1\n" (Just "out-1")
+                result !! 3 @?= CodeBlock "hs" "print 3\n" (Just "out-3")
+
+                result !! 2 @?= CodeBlock "python" "print('x')\n" Nothing
+            ]
+        , testGroup
+            "generatedMarkedScript"
+            [ testCase "inserts marker print after each block" $ do
+                let blocks =
+                        [ (10, [HaskellLine "print 10"])
+                        , (11, [HaskellLine "print 11"])
+                        ]
+                let ls = generatedMarkedScript blocks
+
+                assertBool "has first line" (HaskellLine "print 10" `elem` ls)
+                assertBool "has second line" (HaskellLine "print 11" `elem` ls)
+
+                let m10 = mkMarker 10
+                    m11 = mkMarker 11
+
+                assertBool
+                    "has putStrLn marker 10"
+                    (HaskellLine ("putStrLn " <> T.pack (show (T.unpack m10))) `elem` ls)
+
+                assertBool
+                    "has putStrLn marker 11"
+                    (HaskellLine ("putStrLn " <> T.pack (show (T.unpack m11))) `elem` ls)
+
+                assertBool "has Blank separators" (Blank `elem` ls)
+            ]
+        , testGroup
+            "parseBlocks"
+            [ testCase "extracts only haskell blocks and preserves indices" $ do
+                let segs =
+                        [ (0, Prose "intro\n")
+                        , (1, CodeBlock "python" "print('x')\n" Nothing)
+                        , (2, CodeBlock "haskell" "print 42\n" Nothing)
+                        , (3, CodeBlock "hs" "print 99\n" Nothing)
+                        , (4, Prose "outro\n")
+                        ]
+
+                let (_meta, indexedBlocks) = parseBlocks segs
+                map fst indexedBlocks @?= [2, 3]
+
+                case indexedBlocks of
+                    [(2, ls2), (3, ls3)] -> do
+                        assertBool "block 2 non-empty lines" (not (null ls2))
+                        assertBool "block 3 non-empty lines" (not (null ls3))
+                    other -> assertFailure $ "unexpected indexedBlocks: " ++ show other
+            , testCase "no haskell blocks => empty indexedBlocks and empty-ish meta" $ do
+                let segs =
+                        [ (0, Prose "intro\n")
+                        , (1, CodeBlock "python" "print('x')\n" Nothing)
+                        , (2, Prose "outro\n")
+                        ]
+                let (meta, indexedBlocks) = parseBlocks segs
+                indexedBlocks @?= []
+                metaDeps meta @?= []
+            ]
+        , testGroup
+            "processNotebook"
+            [ testCase "no code blocks => returns input unchanged" $ do
+                let input = T.unlines ["# Title", "", "Just prose.", ""]
+                out <- processNotebook input
+                out @?= input
+            , testCase "non-haskell code blocks only => returns input unchanged" $ do
+                let input =
+                        T.unlines
+                            [ "# Title"
+                            , ""
+                            , "```python"
+                            , "print('hello')"
+                            , "```"
+                            , ""
+                            , "more prose"
+                            ]
+                out <- processNotebook input
+                out @?= input
+            ]
+        ]
diff --git a/test/Test/Parser.hs b/test/Test/Parser.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/Parser.hs
@@ -0,0 +1,159 @@
+module Test.Parser (parseTests) where
+
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (
+    assertBool,
+    assertFailure,
+    testCase,
+    (@?=),
+ )
+
+import qualified Data.Text as T
+import ScriptHs.Parser (
+    CabalMeta (metaDeps, metaExts, metaGhcOptions),
+    Line (..),
+    ScriptFile (scriptLines, scriptMeta),
+    parseScript,
+ )
+
+parseTests :: TestTree
+parseTests =
+    testGroup
+        "Parse"
+        [ testGroup
+            "Line classification"
+            [ testCase "blank line" $ do
+                let sf = parseScript "\n"
+                scriptLines sf @?= [Blank]
+            , testCase "import" $ do
+                let sf = parseScript "import Data.Text (Text)\n"
+                case scriptLines sf of
+                    [Import t] -> assertBool "import text" (T.isPrefixOf "import " t)
+                    other -> assertFailure $ "expected Import, got: " ++ show other
+            , testCase "qualified import" $ do
+                let sf = parseScript "import qualified Data.Map as Map\n"
+                case scriptLines sf of
+                    [Import t] -> assertBool "qualified" (T.isInfixOf "qualified" t)
+                    other -> assertFailure $ "expected Import, got: " ++ show other
+            , testCase "ghci command :set" $ do
+                let sf = parseScript ":set -XOverloadedStrings\n"
+                case scriptLines sf of
+                    [GhciCommand t] -> t @?= ":set -XOverloadedStrings"
+                    other -> assertFailure $ "expected GhciCommand, got: " ++ show other
+            , testCase "ghci command :def!" $ do
+                let sf = parseScript ":def! declareColumns \\s -> return s\n"
+                case scriptLines sf of
+                    [GhciCommand t] -> assertBool ":def!" (T.isPrefixOf ":def!" t)
+                    other -> assertFailure $ "expected GhciCommand, got: " ++ show other
+            , testCase "pragma" $ do
+                let sf = parseScript "{-# LANGUAGE TemplateHaskell #-}\n"
+                case scriptLines sf of
+                    [Pragma t] -> assertBool "pragma" (T.isPrefixOf "{-#" t)
+                    other -> assertFailure $ "expected Pragma, got: " ++ show other
+            , testCase "haskell line" $ do
+                let sf = parseScript "print (5 + 5)\n"
+                case scriptLines sf of
+                    [HaskellLine t] -> t @?= "print (5 + 5)"
+                    other -> assertFailure $ "expected HaskellLine, got: " ++ show other
+            , testCase "IO bind line" $ do
+                let sf = parseScript "x <- getLine\n"
+                case scriptLines sf of
+                    [HaskellLine t] -> assertBool "has <-" (T.isInfixOf "<-" t)
+                    other -> assertFailure $ "expected HaskellLine, got: " ++ show other
+            , testCase "TH splice line" $ do
+                let sf = parseScript "$(declareColumns iris)\n"
+                case scriptLines sf of
+                    [HaskellLine t] -> assertBool "has $(" (T.isPrefixOf "_ = ();" t)
+                    other -> assertFailure $ "expected HaskellLine, got: " ++ show other
+            ]
+        , testGroup
+            "Cabal metadata"
+            [ testCase "build-depends" $ do
+                let sf = parseScript "-- cabal: build-depends: base, text, containers\n"
+                (metaDeps . scriptMeta) sf @?= ["base", "text", "containers"]
+            , testCase "default-extensions" $ do
+                let sf =
+                        parseScript "-- cabal: default-extensions: TemplateHaskell, TypeApplications\n"
+                (metaExts . scriptMeta) sf @?= ["TemplateHaskell", "TypeApplications"]
+            , testCase "ghc-options" $ do
+                let sf = parseScript "-- cabal: ghc-options: -threaded, -O2\n"
+                (metaGhcOptions . scriptMeta) sf @?= ["-threaded", "-O2"]
+            , testCase "metadata stripped from lines" $ do
+                let input =
+                        T.unlines
+                            [ "-- cabal: build-depends: base"
+                            , "import Data.Text"
+                            ]
+                let sf = parseScript input
+                (length . scriptLines) sf @?= 1
+                case scriptLines sf of
+                    [Import _] -> pure ()
+                    other -> assertFailure $ "expected [Import], got: " ++ show other
+            , testCase "multiple metadata lines merge" $ do
+                let input =
+                        T.unlines
+                            [ "-- cabal: build-depends: base, text"
+                            , "-- cabal: build-depends: containers"
+                            , "-- cabal: default-extensions: GADTs"
+                            ]
+                let sf = parseScript input
+                (metaDeps . scriptMeta) sf @?= ["base", "text", "containers"]
+                (metaExts . scriptMeta) sf @?= ["GADTs"]
+            , testCase "unknown cabal key is ignored" $ do
+                let sf = parseScript "-- cabal: foo: bar, baz\n"
+                (metaDeps . scriptMeta) sf @?= []
+                (metaExts . scriptMeta) sf @?= []
+            ]
+        , testGroup
+            "Multi-line scripts"
+            [ testCase "interleaved imports and expressions" $ do
+                let input =
+                        T.unlines
+                            [ "import Data.Text (Text)"
+                            , ""
+                            , "x <- getLine"
+                            , ""
+                            , "import Data.Map (Map)"
+                            , ""
+                            , "print x"
+                            ]
+                let sf = parseScript input
+                let ls = scriptLines sf
+                length ls @?= 7 -- 3 code + 4 blanks (trailing newline)
+                case filter notBlank ls of
+                    [Import _, HaskellLine _, Import _, HaskellLine _] -> pure ()
+                    other -> assertFailure $ "unexpected structure: " ++ show other
+            , testCase "empty input" $ do
+                let sf = parseScript ""
+                scriptLines sf @?= []
+                metaDeps (scriptMeta sf) @?= []
+            , testCase "no trailing newline" $ do
+                let sf = parseScript "print 42"
+                case scriptLines sf of
+                    [HaskellLine t] -> t @?= "print 42"
+                    other -> assertFailure $ "expected HaskellLine, got: " ++ show other
+            ]
+        , testGroup
+            "Edge cases"
+            [ testCase "comment that looks like cabal but isn't" $ do
+                let sf = parseScript "-- cabal is great\n"
+
+                case scriptLines sf of
+                    [HaskellLine _] -> pure ()
+                    other -> assertFailure $ "expected HaskellLine, got: " ++ show other
+            , testCase "regular comment" $ do
+                let sf = parseScript "-- this is a comment\n"
+                case scriptLines sf of
+                    [HaskellLine t] -> assertBool "comment" (T.isPrefixOf "--" t)
+                    other -> assertFailure $ "expected HaskellLine, got: " ++ show other
+            , testCase "indented import is haskell line" $ do
+                let sf = parseScript "  import Data.Text\n"
+                case scriptLines sf of
+                    [HaskellLine _] -> pure ()
+                    other -> assertFailure $ "expected HaskellLine, got: " ++ show other
+            ]
+        ]
+
+notBlank :: Line -> Bool
+notBlank Blank = False
+notBlank _ = True
diff --git a/test/Test/Render.hs b/test/Test/Render.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/Render.hs
@@ -0,0 +1,155 @@
+module Test.Render (renderTests) where
+
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (Assertion, assertBool, testCase, (@?=))
+
+import Data.Text (Text)
+import qualified Data.Text as T
+import ScriptHs.Parser (Line (..))
+import ScriptHs.Render (toGhciScript)
+
+renderTests :: TestTree
+renderTests =
+    testGroup
+        "Render"
+        [ testGroup
+            "Single lines"
+            [ testCase "plain expression stays unwrapped" $ do
+                let result = toGhciScript [HaskellLine "print 42"]
+                assertNotWrapped result
+            , testCase "import stays unwrapped" $ do
+                let result = toGhciScript [Import "import Data.Text"]
+                assertNotWrapped result
+            , testCase "pragma stays unwrapped" $ do
+                let result = toGhciScript [Import "{-# LANGUAGE GADTs #-}"]
+                assertNotWrapped result
+            , testCase "ghci command stays unwrapped" $ do
+                let result = toGhciScript [GhciCommand ":set -XOverloadedStrings"]
+                let ls = nonEmpty result
+                ls @?= [":set -XOverloadedStrings"]
+            , testCase "IO bind gets wrapped" $ do
+                let result = toGhciScript [HaskellLine "x <- getLine"]
+                assertWrapped result ["x <- getLine"]
+            , testCase "blank line preserved" $ do
+                let result = toGhciScript [Blank]
+                assertBool "has blank" (T.isInfixOf "\n\n" result || result == "\n")
+            ]
+        , testGroup
+            "IO line isolation"
+            [ testCase "consecutive IO binds each get own block" $ do
+                let result =
+                        toGhciScript
+                            [ HaskellLine "x <- getLine"
+                            , HaskellLine "y <- getLine"
+                            ]
+                let blocks = splitBlocks result
+                assertBool
+                    ("expected 2 blocks, got " ++ show (length blocks))
+                    (length blocks == 2)
+            , testCase "IO bind between pure code splits correctly" $ do
+                let result =
+                        toGhciScript
+                            [ HaskellLine "let x = 5"
+                            , HaskellLine "y <- getLine"
+                            , HaskellLine "print y"
+                            ]
+                let blocks = splitBlocks result
+                assertBool
+                    ("expected 1 block, got " ++ show (length blocks) ++ ": " ++ show blocks)
+                    (length blocks == 1)
+            ]
+        , testGroup
+            "Multi-line blocks"
+            [ testCase "consecutive pure lines grouped" $ do
+                let result =
+                        toGhciScript
+                            [ HaskellLine "let"
+                            , HaskellLine "  x = 5"
+                            , HaskellLine "  y = 10"
+                            ]
+                let blocks = splitBlocks result
+                length blocks @?= 1
+            , testCase "consecutive do-notation lines grouped" $ do
+                let result =
+                        toGhciScript
+                            [ HaskellLine "do"
+                            , HaskellLine "  x <- pure 5"
+                            , HaskellLine "  y <- pure 10"
+                            , HaskellLine "  pure $ x + y"
+                            ]
+                let blocks = splitBlocks result
+                length blocks @?= 1
+            , testCase "consecutive do-notation lines with space are grouped" $ do
+                let result =
+                        toGhciScript
+                            [ HaskellLine "do"
+                            , HaskellLine "  x <- pure 5"
+                            , -- indentation should ignore these blank lines.
+                              Blank
+                            , Blank
+                            , HaskellLine "  y <- pure 10"
+                            , HaskellLine "  pure $ x + y"
+                            ]
+                let blocks = splitBlocks result
+                length blocks @?= 1
+            , testCase "blank separates blocks" $ do
+                let result =
+                        toGhciScript
+                            [ HaskellLine "print 1"
+                            , Blank
+                            , HaskellLine "print 2"
+                            ]
+                let ls = nonEmpty result
+                assertBool "has print 1" (any (T.isInfixOf "print 1") ls)
+                assertBool "has print 2" (any (T.isInfixOf "print 2") ls)
+            ]
+        , testGroup
+            "Full script transform"
+            [ testCase "typical script" $ do
+                let result =
+                        toGhciScript
+                            [ Import "import qualified DataFrame as D"
+                            , Blank
+                            , HaskellLine "iris <- D.readParquet \"data/iris.parquet\""
+                            , Blank
+                            , Import "import Data.Text (Text)"
+                            , Blank
+                            , HaskellLine "print iris"
+                            ]
+                assertBool "has import D" (T.isInfixOf "import qualified DataFrame as D" result)
+                assertBool "has :{" (T.isInfixOf ":{" result)
+                assertBool "has print" (T.isInfixOf "print iris" result)
+            ]
+        ]
+
+splitBlocks :: Text -> [[Text]]
+splitBlocks = go . T.lines
+  where
+    go [] = []
+    go (l : ls)
+        | T.strip l == ":{" =
+            let (block, rest) = span (\x -> T.strip x /= ":}") ls
+                rest' = drop 1 rest
+             in block : go rest'
+        | otherwise = go ls
+
+nonEmpty :: Text -> [Text]
+nonEmpty = filter (not . T.null . T.strip) . T.lines
+
+assertNotWrapped :: Text -> Assertion
+assertNotWrapped t =
+    assertBool
+        ("expected no wrapping, got: " ++ T.unpack t)
+        (not (T.isInfixOf ":{" t))
+
+assertWrapped :: Text -> [Text] -> Assertion
+assertWrapped t expectedInner = do
+    let blocks = splitBlocks t
+    assertBool
+        ("expected at least one block, got: " ++ show blocks)
+        (not (null blocks))
+    let innerLines = map T.strip (concat blocks)
+    let expected = map T.strip expectedInner
+    assertBool
+        ("expected " ++ show expected ++ " in blocks, got: " ++ show innerLines)
+        (all (`elem` innerLines) expected)
