diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## 1.0.0
+
+Initial release.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2026 Marius Lysakerrud
+
+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,54 @@
+# haskell-scfg
+
+[![builds.sr.ht status](https://builds.sr.ht/~mariusl/haskell-scfg.svg)](https://builds.sr.ht/~mariusl/haskell-scfg?)
+
+Haskell library for [scfg](https://codeberg.org/emersion/scfg).
+
+## Usage
+
+```haskell
+{-# LANGUAGE OverloadedStrings #-}
+
+import Data.Scfg (
+    Directive (directiveChildren, directiveName, directiveParams),
+    ParseError (errorMessage),
+    parse,
+ )
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Text.IO as TIO
+
+config :: Text
+config =
+    "train \"Shinkansen\" {\n\
+    \  model \"E5\" {\n\
+    \    max-speed 320km/h\n\
+    \    weight 453.5t\n\
+    \    lines-served \"Tōhoku\" \"Hokkaido\"\n\
+    \  }\n\
+    \  model \"E7\" {\n\
+    \    max-speed 275km/h\n\
+    \    weight 540t\n\
+    \    lines-served \"Hokuriku\" \"Jōetsu\"\n\
+    \  }\n\
+    \}\n"
+
+main :: IO ()
+main = case parse config of
+    Left err -> TIO.putStrLn $ "parse error: " <> errorMessage err
+    Right cfg -> do
+        let models = concatMap directiveChildren cfg
+        mapM_ (\m -> TIO.putStrLn $ T.unwords (directiveName m : directiveParams m)) models
+
+```
+
+## Contributing
+
+Feel free to send patches to my
+[public-inbox](https://lists.sr.ht/~mariusl/public-inbox). Please note
+what change the patch will have on the version in accordance with the
+[Haskell Package Versioning Policy (PVP)](https://pvp.haskell.org/).
+
+## License
+
+MIT
diff --git a/scfg.cabal b/scfg.cabal
new file mode 100644
--- /dev/null
+++ b/scfg.cabal
@@ -0,0 +1,61 @@
+cabal-version:   3.0
+name:            scfg
+version:         1.0.0
+synopsis:        SCFG parser for Haskell
+description:
+    A simple parser for SCFG (simple configuration format) files
+
+author:          Marius Lysakerrud
+maintainer:      mail@marius.pm
+license:         MIT
+license-file:    LICENSE
+homepage:        https://git.sr.ht/~mariusl/haskell-scfg
+bug-reports:     https://lists.sr.ht/~mariusl/public-inbox
+category:        Data
+build-type:      Simple
+tested-with:     GHC ==9.8.2 || ==9.10.3
+extra-doc-files:
+    CHANGELOG.md
+    README.md
+
+common warnings
+    ghc-options: -Wall
+
+library
+    import:           warnings
+    exposed-modules:
+        Data.Scfg
+        Data.Scfg.Formatter
+        Data.Scfg.Types
+
+    other-modules:    Data.Scfg.Parser
+    build-depends:
+        , base        >=4.19.0.0 && <4.21
+        , megaparsec  >=9.7.0    && <9.8
+        , text        >=2.1.0    && <2.2
+
+    hs-source-dirs:   src
+    default-language: Haskell2010
+
+test-suite scfg-test
+    import:           warnings
+    default-language: Haskell2010
+    other-modules:
+        Data.Scfg
+        Data.Scfg.Formatter
+        Data.Scfg.Parser
+        Data.Scfg.Types
+
+    type:             exitcode-stdio-1.0
+    hs-source-dirs:   test src
+    main-is:          Test.hs
+    build-depends:
+        , base        >=4.19.0.0 && <4.21
+        , hspec       >=2.11.0   && <2.12
+        , megaparsec  >=9.7.0    && <9.8
+        , temporary   >=1.3      && <1.4
+        , text        >=2.1.0    && <2.2
+
+source-repository head
+    type:     git
+    location: https://git.sr.ht/~mariusl/haskell-scfg
diff --git a/src/Data/Scfg.hs b/src/Data/Scfg.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Scfg.hs
@@ -0,0 +1,42 @@
+{- | Parser for the [scfg](https://codeberg.org/emersion/scfg)
+configuration file format.
+-}
+module Data.Scfg (
+  parse,
+  parseFile,
+  format,
+  formatFile,
+  Directive (..),
+  Config,
+  ParseError (..),
+) where
+
+import qualified Data.Scfg.Formatter as F
+import qualified Data.Scfg.Parser as P
+import Data.Scfg.Types
+import Data.Text (Text)
+import qualified Data.Text.IO as TIO
+
+{- | Parse scfg from 'Text'. Returns a 'Data.Scfg.Types.ParseError' if
+the input is not valid scfg.
+-}
+parse :: Text -> Either ParseError Config
+parse = P.parseConfig
+
+{- | Parse scfg from a file. Returns a 'Data.Scfg.Types.ParseError' if
+the input is not valid scfg or if the file cannot be read.
+-}
+parseFile :: FilePath -> IO (Either ParseError Config)
+parseFile path = parse <$> TIO.readFile path
+
+{- | Format a 'Config' as a canonical 'Text' representation. All words
+are double-quoted and blocks indented with tabs.
+-}
+format :: Config -> Text
+format = F.format
+
+{- | Format a 'Config' and write it to a file. Returns an 'IOError' if
+the file cannot be written.
+-}
+formatFile :: FilePath -> Config -> IO ()
+formatFile path = TIO.writeFile path . F.format
diff --git a/src/Data/Scfg/Formatter.hs b/src/Data/Scfg/Formatter.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Scfg/Formatter.hs
@@ -0,0 +1,41 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Canonical formatter for scfg configurations.
+module Data.Scfg.Formatter (format) where
+
+import Data.Scfg.Types
+import Data.Text (Text)
+import qualified Data.Text as T
+
+{- | Format a 'Config' as canonical scfg text. All words are
+double-quoted and blocks are indented with tabs.
+-}
+format :: [Directive] -> Text
+format = foldMap (formatDirective 0)
+
+formatDirective :: Int -> Directive -> Text
+formatDirective depth d =
+  indent
+    <> quoteWord (directiveName d)
+    <> params
+    <> block
+    <> "\n"
+ where
+  indent = T.replicate depth "\t"
+  params = case directiveParams d of
+    [] -> ""
+    ps -> " " <> T.unwords (map quoteWord ps)
+  block = case directiveChildren d of
+    [] -> ""
+    cs ->
+      " {\n"
+        <> foldMap (formatDirective (depth + 1)) cs
+        <> indent
+        <> "}"
+
+quoteWord :: Text -> Text
+quoteWord t = "\"" <> T.concatMap escape t <> "\""
+ where
+  escape '"' = "\\\""
+  escape '\\' = "\\\\"
+  escape c = T.singleton c
diff --git a/src/Data/Scfg/Parser.hs b/src/Data/Scfg/Parser.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Scfg/Parser.hs
@@ -0,0 +1,85 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Data.Scfg.Parser (parseConfig) where
+
+import Control.Monad (void)
+import Data.Char (isControl)
+import Data.Functor (($>))
+import Data.List.NonEmpty (head)
+import Data.Maybe (catMaybes, fromMaybe)
+import Data.Scfg.Types
+import Data.Text (Text)
+import qualified Data.Text as T
+import Data.Void (Void)
+import Text.Megaparsec hiding (ParseError)
+import Text.Megaparsec.Char
+import Prelude hiding (head)
+
+type Parser = Parsec Void Text
+
+parseConfig :: Text -> Either ParseError Config
+parseConfig input = case parse config "" input of
+  Left bundle ->
+    let firstError = head (bundleErrors bundle)
+        offset = errorOffset firstError
+        (_, state) = reachOffset offset (bundlePosState bundle)
+        SourcePos _ l c = pstateSourcePos state
+     in Left
+          ParseError
+            { errorLine = unPos l
+            , errorColumn = unPos c
+            , errorMessage = T.pack (errorBundlePretty bundle)
+            }
+  Right c -> Right c
+
+config :: Parser Config
+config = catMaybes <$> many line <* eof
+
+line :: Parser (Maybe Directive)
+line = hspace *> ((newline $> Nothing) <|> (comment $> Nothing) <|> (Just <$> directive))
+
+comment :: Parser ()
+comment = (char '#' *> takeWhileP Nothing isVChar *> newline) $> ()
+
+directive :: Parser Directive
+directive = do
+  n <- word
+  params <- many (try (hspace1 *> word))
+  children <- optional (try (hspace1 *> block))
+  _ <- hspace *> (void newline <|> eof)
+  pure (Directive n params (fromMaybe [] children))
+
+block :: Parser [Directive]
+block = do
+  _ <- char '{'
+  _ <- hspace *> newline
+  ds <- catMaybes <$> many (try line)
+  _ <- hspace *> char '}'
+  pure ds
+
+word :: Parser Text
+word = dquoteWord <|> squoteWord <|> atom
+
+atom :: Parser Text
+atom = T.concat <$> some (escPair <|> takeWhile1P (Just "character") isAtomChar)
+
+dquoteWord :: Parser Text
+dquoteWord = char '"' *> (T.concat <$> many (escPair <|> takeWhile1P (Just "character") isDqChar)) <* char '"'
+
+squoteWord :: Parser Text
+squoteWord = char '\'' *> (takeWhile1P (Just "character") isSqChar <|> pure "") <* char '\''
+
+escPair :: Parser Text
+escPair = T.singleton <$> (char '\\' *> satisfy isVChar)
+
+isVChar :: Char -> Bool
+isVChar c = c == '\t' || (not (isControl c) && c /= '\n')
+
+isAtomChar :: Char -> Bool
+isAtomChar c = not (isControl c) && c /= ' ' && c /= '\n' && c /= '{' && c /= '}' && c /= '"' && c /= '\\' && c /= '\''
+
+isDqChar :: Char -> Bool
+isDqChar c = (c == '\t' || not (isControl c)) && c /= '"' && c /= '\\' && c /= '\n'
+
+isSqChar :: Char -> Bool
+isSqChar c = (c == '\t' || not (isControl c)) && c /= '\'' && c /= '\n'
diff --git a/src/Data/Scfg/Types.hs b/src/Data/Scfg/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/Scfg/Types.hs
@@ -0,0 +1,40 @@
+{-# LANGUAGE InstanceSigs #-}
+
+-- | Core types for the scfg configuration file format.
+module Data.Scfg.Types (
+  Directive (..),
+  Config,
+  ParseError (..),
+) where
+
+import Control.Exception (Exception (..))
+import Data.Text (Text)
+import qualified Data.Text as T
+
+{- | A directive: a name, zero or more parameteres, and zero or more
+child directives.
+-}
+data Directive = Directive
+  { directiveName :: Text
+  , directiveParams :: [Text]
+  , directiveChildren :: [Directive]
+  }
+  deriving (Show, Eq)
+
+-- | A parsed scfg configuration: a list of top-level directives.
+type Config = [Directive]
+
+-- | An error that occurred while parsing a scfg configuration.
+data ParseError = ParseError
+  { errorLine :: Int
+  -- ^ Line number of parse error
+  , errorColumn :: Int
+  -- ^ Column number of parse error
+  , errorMessage :: Text
+  -- ^ Human-readable error message describing the parse error
+  }
+  deriving (Show, Eq)
+
+instance Exception ParseError where
+  displayException :: ParseError -> String
+  displayException = T.unpack . errorMessage
diff --git a/test/Test.hs b/test/Test.hs
new file mode 100644
--- /dev/null
+++ b/test/Test.hs
@@ -0,0 +1,165 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Main (main) where
+
+import Data.Either (isLeft)
+import Data.Scfg
+import System.IO (hClose, hPutStr)
+import System.IO.Temp (withSystemTempFile)
+import Test.Hspec
+
+main :: IO ()
+main = hspec $ do
+    describe "parse" $ do
+        describe "directives" $ do
+            it "parses a bare directive with no params" $
+                parse "foo\n" `shouldBe` Right [Directive "foo" [] []]
+
+            it "parses a directive with params" $
+                parse "foo bar baz\n" `shouldBe` Right [Directive "foo" ["bar", "baz"] []]
+
+            it "parses multiple directives" $
+                parse "foo\nbar\n" `shouldBe` Right [Directive "foo" [] [], Directive "bar" [] []]
+
+            it "parses an empty config" $
+                parse "" `shouldBe` Right []
+
+            it "accepts missing final newline" $
+                parse "foo" `shouldBe` Right [Directive "foo" [] []]
+
+            it "ignores blank lines" $
+                parse "foo\n\nbar\n" `shouldBe` Right [Directive "foo" [] [], Directive "bar" [] []]
+
+        describe "blocks" $ do
+            it "parses a directive with a block" $
+                parse "foo {\nbar\n}\n" `shouldBe` Right [Directive "foo" [] [Directive "bar" [] []]]
+
+            it "allows whitespace after opening brace" $
+                parse "foo {  \nbar\n}\n" `shouldBe` Right [Directive "foo" [] [Directive "bar" [] []]]
+
+        describe "trailing whitespace" $ do
+            it "allows trailing whitespace after params" $
+                parse "foo bar \n" `shouldBe` Right [Directive "foo" ["bar"] []]
+
+            it "allows trailing whitespace with no params" $
+                parse "foo \n" `shouldBe` Right [Directive "foo" [] []]
+
+        describe "comments" $ do
+            it "ignores comments" $
+                parse "# a comment\nfoo\n" `shouldBe` Right [Directive "foo" [] []]
+
+            it "rejects CTL characters in comments" $
+                parse "# bad\1&comment\nfoo\n" `shouldSatisfy` isLeft
+
+    describe "ParseError" $ do
+        it "includes line and column of the error" $ do
+            let result = parse "foo\nbar 'unterminated\n"
+            case result of
+                Right _ -> expectationFailure "expected parse error"
+                Left err -> do
+                    errorLine err `shouldBe` 2
+                    errorColumn err `shouldBe` 5
+
+        describe "atoms" $ do
+            it "rejects single quote in atom" $
+                parse "foo bar'baz\n" `shouldSatisfy` isLeft
+
+            it "rejects CTL characters" $
+                parse "foo bar\1&baz\n" `shouldSatisfy` isLeft
+
+            it "allows C1 control characters" $
+                parse "foo bar\x80baz\n" `shouldBe` Right [Directive "foo" ["bar\x80baz"] []]
+
+        describe "double-quoted words" $ do
+            it "parses double-quoted params" $
+                parse "foo \"hello world\"\n" `shouldBe` Right [Directive "foo" ["hello world"] []]
+
+            it "rejects CTL characters" $
+                parse "foo \"bar\1&baz\"\n" `shouldSatisfy` isLeft
+
+            it "allows tab" $
+                parse "foo \"bar\tbaz\"\n" `shouldBe` Right [Directive "foo" ["bar\tbaz"] []]
+
+            it "allows C1 control characters" $
+                parse "foo \"bar\x80baz\"\n" `shouldBe` Right [Directive "foo" ["bar\x80baz"] []]
+
+        describe "single-quoted words" $ do
+            it "parses single-quoted params" $
+                parse "foo 'hello world'\n" `shouldBe` Right [Directive "foo" ["hello world"] []]
+
+            it "rejects newline" $
+                parse "foo 'bar\nbaz'\n" `shouldSatisfy` isLeft
+
+            it "allows tab" $
+                parse "foo 'bar\tbaz'\n" `shouldBe` Right [Directive "foo" ["bar\tbaz"] []]
+
+            it "allows C1 control characters" $
+                parse "foo 'bar\x80baz'\n" `shouldBe` Right [Directive "foo" ["bar\x80baz"] []]
+
+        describe "escape sequences" $ do
+            it "parses escape sequences in atoms" $
+                parse "foo bar\\=baz\n" `shouldBe` Right [Directive "foo" ["bar=baz"] []]
+
+            it "parses escape sequences in double-quoted words" $
+                parse "foo \"bar\\\"baz\"\n" `shouldBe` Right [Directive "foo" ["bar\"baz"] []]
+
+            it "rejects CTL character" $
+                parse "foo bar\\\1&baz\n" `shouldSatisfy` isLeft
+
+            it "allows tab" $
+                parse "foo bar\\\tbaz\n" `shouldBe` Right [Directive "foo" ["bar\tbaz"] []]
+
+        describe "parseFile" $ do
+            it "parses a file" $
+                withSystemTempFile "scfg" $ \path h -> do
+                    hPutStr h "foo bar\n"
+                    hClose h
+                    result <- parseFile path
+                    result `shouldBe` Right [Directive "foo" ["bar"] []]
+
+            it "returns Left on parse error" $
+                withSystemTempFile "scfg" $ \path h -> do
+                    hPutStr h "foo 'unterminated\n"
+                    hClose h
+                    result <- parseFile path
+                    result `shouldSatisfy` isLeft
+
+    describe "format" $ do
+        it "formats a bare directive" $
+            format [Directive "foo" [] []] `shouldBe` "\"foo\"\n"
+
+        it "formats a directive with params" $
+            format [Directive "foo" ["bar", "baz"] []] `shouldBe` "\"foo\" \"bar\" \"baz\"\n"
+
+        it "formats a directive with a block" $
+            format [Directive "foo" [] [Directive "bar" [] []]]
+                `shouldBe` "\"foo\" {\n\t\"bar\"\n}\n"
+
+        it "formats nested blocks with correct indentation" $
+            format [Directive "foo" [] [Directive "bar" [] [Directive "baz" [] []]]]
+                `shouldBe` "\"foo\" {\n\t\"bar\" {\n\t\t\"baz\"\n\t}\n}\n"
+
+        it "escapes double quotes in names and params" $
+            format [Directive "fo\"o" ["ba\"r"] []] `shouldBe` "\"fo\\\"o\" \"ba\\\"r\"\n"
+
+    describe "integration" $ do
+        it "parses the spec example" $
+            parse
+                "train \"Shinkansen\" {\n\
+                \  model \"E5\" {\n\
+                \    max-speed 320km/h\n\
+                \    weight 453.5t\n\
+                \  }\n\
+                \}\n"
+                `shouldBe` Right
+                    [ Directive
+                        "train"
+                        ["Shinkansen"]
+                        [ Directive
+                            "model"
+                            ["E5"]
+                            [ Directive "max-speed" ["320km/h"] []
+                            , Directive "weight" ["453.5t"] []
+                            ]
+                        ]
+                    ]
