diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2016-2018, John MacFarlane.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * The names of the contributors may not be used to endorse or
+      promote products derived from this software without specific
+      prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,5 @@
+# skylighting-format-typst
+
+This package provides functions to render syntax-highlighting
+as typst.
+
diff --git a/skylighting-format-typst.cabal b/skylighting-format-typst.cabal
new file mode 100644
--- /dev/null
+++ b/skylighting-format-typst.cabal
@@ -0,0 +1,36 @@
+name:                skylighting-format-typst
+version:             0.1
+synopsis:            Typst formatter for skylighting syntax highlighting library
+description:         This module allows tokens produced by skylighting-core
+                     to be rendered as Typst.
+homepage:            https://github.com/jgm/skylighting
+license:             BSD3
+license-file:        LICENSE
+author:              John MacFarlane
+maintainer:          jgm@berkeley.edu
+copyright:           (C) 2024 John MacFarlane
+category:            Text
+build-type:          Simple
+extra-source-files:  README.md
+
+cabal-version:       >=1.10
+
+source-repository head
+  type:              git
+  location:          https://github.com/jgm/skylighting.git
+
+library
+  exposed-modules:     Skylighting.Format.Typst
+  other-extensions:    CPP
+  build-depends:       base >= 4.8 && < 5.0,
+                       skylighting-core,
+                       text,
+                       containers
+  hs-source-dirs:      src
+  ghc-prof-options:    -fprof-auto-exported
+  default-language:    Haskell2010
+  ghc-options:         -Wall
+  if impl(ghc >= 8.4)
+    ghc-options:       -fhide-source-paths
+  if impl(ghc >= 8.10)
+    ghc-options:      -Wunused-packages
diff --git a/src/Skylighting/Format/Typst.hs b/src/Skylighting/Format/Typst.hs
new file mode 100644
--- /dev/null
+++ b/src/Skylighting/Format/Typst.hs
@@ -0,0 +1,112 @@
+{-# LANGUAGE CPP                 #-}
+{-# LANGUAGE OverloadedStrings   #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+module Skylighting.Format.Typst (
+         formatTypstInline
+       , formatTypstBlock
+       , styleToTypst
+       ) where
+
+import Control.Monad (mplus)
+import Data.List (sort)
+import qualified Data.Map as Map
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Skylighting.Types
+import qualified Data.Map as M
+import Data.Maybe (fromMaybe)
+#if !MIN_VERSION_base(4,11,0)
+import Data.Semigroup
+#endif
+
+-- | Formats tokens as Typst using custom commands inside
+-- @|@ characters. Assumes that @|@ is defined as a short verbatim
+-- command by the macros produced by 'styleToTypst'.
+-- A @KeywordTok@ is rendered using @\\KeywordTok{..}@, and so on.
+formatTypstInline :: FormatOptions -> [SourceLine] -> Text
+formatTypstInline _opts = Text.intercalate newline . map sourceLineToTypst
+
+newline :: Text
+newline =  "#EndLine()\n"
+
+sourceLineToTypst :: SourceLine -> Text
+sourceLineToTypst = mconcat . map tokenToTypst
+
+tokenToTypst :: Token -> Text
+tokenToTypst (toktype, txt) =
+  "#" <> Text.pack (show toktype) <> "(" <> doubleQuoted txt <> ");"
+
+doubleQuoted :: Text -> Text
+doubleQuoted t = "\"" <> escape t <> "\""
+ where
+  escape = Text.concatMap escapeChar
+  escapeChar '\\' = "\\\\"
+  escapeChar '"' = "\\\""
+  escapeChar c = Text.singleton c
+
+-- Typst
+
+-- | Format tokens as a Typst @Highlighting@ environment inside a
+-- Skylighting block that can be styled. @Skylighting@ is
+-- defined by the macros produced by 'styleToTypst'.
+formatTypstBlock :: FormatOptions -> [SourceLine] -> Text
+formatTypstBlock opts ls =
+  "#Skylighting(" <>
+  (if numberLines opts
+       then "number: true, start: " <> Text.pack (show (startNumber opts)) <> ", "
+       else "") <>
+  "(" <> -- an array
+  Text.intercalate "\n" (map (\ln -> "[" <> formatTypstInline opts [ln] <> "],") ls)
+  <> "));"
+
+-- | Converts a 'Style' to a set of Typst macro definitions,
+-- which should be placed in the document's preamble.
+styleToTypst :: Style -> Text
+styleToTypst f =
+  Text.unlines $
+  [ "/* Function definitions for syntax highlighting generated by skylighting: */"
+  , "#let EndLine() = raw(\"\\n\")"
+  , "#let Skylighting(fill: none, number: false, start: 1, sourcelines) = {"
+  , "   let blocks = []"
+  , "   let lnum = start - 1"
+  , "   let bgcolor = " <> maybe "none" toTypstColor (backgroundColor f)
+  , "   for ln in sourcelines {"
+  , "     if number {"
+  , "       lnum = lnum + 1"
+  , "       blocks = blocks + box(width: if start + sourcelines.len() > 999 { 30pt } else { 24pt }, text(" <> lineNumberFill <> "[ #lnum ]))"
+  , "     }"
+  , "     blocks = blocks + ln + EndLine()"
+  , "   }"
+  , "   block(fill: bgcolor, blocks)"
+  , "}"
+  ] <>
+  sort (map (macrodef (defaultColor f) (Map.toList (tokenStyles f)))
+         (enumFromTo KeywordTok NormalTok))
+ where
+   toTypstColor c = "rgb(" <> Text.pack (show (fromColor c :: String)) <> ")"
+   lineNumberFill = case lineNumberColor f of
+                      Nothing -> ""
+                      Just c -> "fill: " <> toTypstColor c <> ", "
+
+macrodef :: Maybe Color -> [(TokenType, TokenStyle)] -> TokenType -> Text
+macrodef defaultcol tokstyles' tokt =
+  "#let " <> Text.pack (show tokt) <> "(s) = " <> (ul . bg . textstyle) ("raw(s)")
+ where tokstyles = M.fromList tokstyles'
+       tokf = fromMaybe defStyle $ M.lookup tokt tokstyles
+       ul x = if tokenUnderline tokf
+                 then "underline(" <> x <> ")"
+                 else x
+       bg x = case tokenBackground tokf of
+                   Nothing        -> x
+                   Just _c -> x -- TODO?
+       textstyle x = "text(" <> bf <> it <> co <> x <> ")"
+       it = if tokenItalic tokf
+               then "style: \"italic\","
+               else ""
+       bf = if tokenBold tokf
+               then "weight: \"bold\","
+               else ""
+       co = case tokenColor tokf `mplus` defaultcol of
+                 Just c -> "fill: rgb(" <>
+                   Text.pack (show (fromColor c :: String)) <> "),"
+                 Nothing -> ""
