diff --git a/docidx.cabal b/docidx.cabal
--- a/docidx.cabal
+++ b/docidx.cabal
@@ -1,5 +1,5 @@
 name:                   docidx
-version:                1.0.0
+version:                1.0.1
 license:                BSD3
 license-file:           LICENSE
 category:               Documentation
@@ -38,6 +38,9 @@
                         directory >= 1.0, filepath >= 1.1, html >= 1.0,
                         MissingH >= 1.1, mtl >= 2, old-locale, tagsoup >= 0.11,
                         time >= 1.1
+  other-modules:   Distribution.DocIdx.Common,
+                        Distribution.DocIdx.Config, Distribution.DocIdx.Html,
+                        Distribution.GhcPkgList
   ghc-options:          -fwarn-tabs -Wall
 
 source-repository head
diff --git a/src/Distribution/DocIdx/Common.hs b/src/Distribution/DocIdx/Common.hs
new file mode 100644
--- /dev/null
+++ b/src/Distribution/DocIdx/Common.hs
@@ -0,0 +1,27 @@
+module Distribution.DocIdx.Common where
+
+import qualified Control.Exception as C
+import System.IO
+
+-- | Application name, used to construct path to data directory, and
+-- in footer of constructed page.
+appName :: String
+appName = "docidx"
+
+-- | Data type for elements of the table of contents.
+data TocItem = TocItem String String
+             | TocSeparator
+             | TocNewline
+               deriving (Eq, Ord, Show)
+
+-- | Try to read the contents of a file, but if it can't be opened,
+-- print an informative warning to stderr and return Nothing.
+tryReadFile :: FilePath -> IO (Maybe String)
+tryReadFile p =
+  C.catch
+    (do d <- readFile p
+        return $ Just d)
+    (\e -> do let err = show (e :: C.IOException)
+              hPutStrLn stderr ("Warning: Couldn't open " ++ p ++ ": " ++ err)
+              return Nothing)
+  
diff --git a/src/Distribution/DocIdx/Config.hs b/src/Distribution/DocIdx/Config.hs
new file mode 100644
--- /dev/null
+++ b/src/Distribution/DocIdx/Config.hs
@@ -0,0 +1,79 @@
+-- Configuration for docidx.
+
+module Distribution.DocIdx.Config where
+
+import Control.Monad
+import Control.Monad.Writer
+import Data.Maybe
+import System.Directory
+import System.FilePath
+
+import Distribution.DocIdx.Common
+
+-- | Name of configuration file within user's application data
+-- directory.
+cfgFile :: FilePath
+cfgFile = "config"
+
+-- | Configuration information for docidx; at present only the
+-- tocExtras part is exposed via the config file, however.
+data DocIdxCfg = DocIdxCfg {
+    pageTitle :: String
+  , pageCss :: [String]
+  , favIcon :: String
+  , tocExtras :: [TocItem]
+  } deriving Show
+
+-- | Default configuration.
+defaultConfig :: DocIdxCfg
+defaultConfig = DocIdxCfg {
+    pageTitle = "Local Haskell package docs"
+  , pageCss = ["http://hackage.haskell.org/packages/hackage.css"
+              ,"file:///Library/Frameworks/GHC.framework/Versions/Current/usr/share/doc/ghc/html/libraries/haddock.css"]
+  , favIcon = "http://hackage.haskell.org/images/Cabal-tiny.png"
+  , tocExtras = []
+  }
+
+-- | Read configuration file if present.
+getConfig :: IO DocIdxCfg
+getConfig = do
+  appDir <- getAppUserDataDirectory appName
+  let cfgPath = joinPath [appDir, cfgFile]
+  there <- doesFileExist cfgPath
+  extras <- if not there
+              then return []
+              else do dm <- tryReadFile cfgPath
+                      case dm of
+                        Nothing -> return []
+                        Just d -> readConfig d
+  return $ defaultConfig { tocExtras = extras }
+
+-- | Read a config file's contents.  At present we're only looking for
+-- TocItems, but other things could be there in the future
+-- (e.g. alternative CSS, etc.)
+readConfig :: String -> IO [TocItem]
+readConfig d = do
+  let (extras, l) = runWriter $ (liftM catMaybes . mapM readConfigLine) $ lines d
+  forM_ l putStrLn
+  return extras
+
+-- | Try to read a single line from the config file.
+readConfigLine :: String -> Writer [String] (Maybe TocItem)
+readConfigLine line = do
+  let ws = words line
+  case ws of
+    [] -> return Nothing
+    ("--":_) -> return Nothing
+    ("extraSeparator":_) -> return $ Just TocSeparator
+    ("extraNewline":_) -> return $ Just TocNewline
+    ("extra":xs) -> if length xs > 1
+                      then let name = unlines $ init xs
+                               url = last xs
+                           in return $ Just $ TocItem name url
+                      else warn "malformed extra" line
+    _ -> warn "unrecognised config line" line
+
+-- | Moan gently about any weird looking lines.
+warn :: String -> String -> Writer [String] (Maybe a)
+warn msg line = do tell ["Warning: " ++ msg ++ ": \"" ++ line ++ "\""]
+                   return Nothing
diff --git a/src/Distribution/DocIdx/Html.hs b/src/Distribution/DocIdx/Html.hs
new file mode 100644
--- /dev/null
+++ b/src/Distribution/DocIdx/Html.hs
@@ -0,0 +1,160 @@
+-- | HTML output for documentation package index.
+
+module Distribution.DocIdx.Html (
+  htmlPage
+) where
+
+import Control.Monad
+import Data.Char (isAlpha, toUpper)
+import Data.List
+import Data.Ord
+import Data.Time
+import Data.Version
+import qualified Data.Map as M
+import System.FilePath
+import System.Locale
+import Text.Html
+
+import Distribution.DocIdx.Common
+import Distribution.DocIdx.Config
+import Distribution.GhcPkgList
+
+-- | Project homepage, for footer.
+homePage :: String
+homePage = "http://hackage.haskell.org/package/docidx"
+
+-- | Create and render entire page.
+htmlPage :: DocIdxCfg -> PackageMap HaddockInfo -> UTCTime -> String
+htmlPage config pkgs now = renderHtml [htmlHeader, htmlBody]
+  where htmlHeader = header << ((thetitle << pageTitle config) : fav : css)
+        fav = thelink ![rel "shortcut icon", href $ favIcon config] << noHtml
+        css = map oneCss (pageCss config)
+        oneCss cp = thelink ![rel "stylesheet",
+                              thetype "text/css", href cp] << noHtml
+        htmlBody = body << (title' ++ toc ++ secs ++ nowFoot)
+          where title' = [h2 << "Local packages with docs"]
+                toc = [htmlToc config am]
+                secs = concatMap (uncurry htmlPkgsAlpha) $ M.assocs am
+                am = alphabetize pkgs
+                now' = formatTime defaultTimeLocale rfc822DateFormat now
+                nowFoot = [p ![theclass "toc"] $
+                           stringToHtml ("Page rendered " ++ now' ++ " by ")
+                           +++ (anchor ![href homePage] <<
+                                         stringToHtml appName)]
+
+-- | An AlphaMap groups packages together by their name's first character.
+type AlphaMap = M.Map Char (PackageMap HaddockInfo)
+
+-- | Group packages together by their name's first character.
+alphabetize :: PackageMap HaddockInfo -> AlphaMap
+alphabetize = foldr addAlpha M.empty
+  where addAlpha (n, vs) = M.insertWith (++) c [(n, vs)]
+          where c = if isAlpha c' then c' else '\0'
+                c' = toUpper $ head n
+
+-- | Generate the table of contents.
+htmlToc :: DocIdxCfg -> AlphaMap -> Html
+htmlToc config am =
+  p ![theclass "toc"] << tocHtml (alphaItems ++ tocExtras config)
+    where tocHtml = intersperse bull . concatMap tocItemHtml
+          alphaItems = map (\k -> TocItem [k] ('#':[k])) $ sort $ M.keys am
+
+-- | Render toc elements to HTML.
+tocItemHtml :: TocItem -> [Html]
+tocItemHtml (TocItem nm path) = [anchor ![href path] << nm]
+tocItemHtml TocSeparator = [mdash]
+tocItemHtml TocNewline = [br] -- Hmmm... you still get the bullets?
+
+-- | Render a collection of packages with the same first character.
+htmlPkgsAlpha :: Char -> PackageMap HaddockInfo -> [Html]
+htmlPkgsAlpha c pm = [heading, packages]
+  where heading = h3 ![theclass "category"] << anchor ![name [c]] << [c]
+        packages = ulist ![theclass "packages"] <<
+                     map (uncurry htmlPkg) pm'
+        pm' = sortBy (comparing (map toUpper . fst)) pm
+
+-- | Render a particularly-named package (all versions of it).
+htmlPkg :: String -> VersionMap HaddockInfo -> Html
+htmlPkg nm vs = li << pvsHtml (flattenPkgVersions nm vs)
+
+-- | Everything we want to know about a particular version of a
+-- package, nicely flattened and ready to use.  (Actually, we'd also
+-- like to use the synopsis, but this isn't exposed through the Cabal
+-- library, sadly.  We could conceivably grab it from the haddock docs
+-- (and hackage for packages with no local docs)  but this
+-- seems excessive so for now we forget about it.
+data PkgVersion = PkgVersion {
+    pvName ::String
+  , pvSynopsis :: Maybe String
+  , pvVersion :: Version
+  , pvExposed :: Bool
+  , pvHaddocks :: Maybe FilePath
+  } deriving (Eq, Ord, Show)
+
+-- | Flatten a given package's various versions into a list of
+-- PkgVersion values, which is much nicer to iterate over when
+-- building the HTML for this package.
+flattenPkgVersions :: String -> VersionMap HaddockInfo -> [PkgVersion]
+flattenPkgVersions nm vs = concatMap (uncurry flatten') $ reverse vs
+  where flatten' :: Version -> [VersionInfo HaddockInfo] -> [PkgVersion]
+        -- We reverse here to put user versions of pkgs before
+        -- identically versioned global versions.
+        flatten' v = concatMap (uncurry flatten3) . reverse
+          where flatten3 :: Bool -> [HaddockInfo] -> [PkgVersion]
+                flatten3 ex [] = [PkgVersion nm Nothing v ex Nothing]
+                flatten3 ex ps = map (mkPv nm v ex) ps
+
+-- | Construct a PkgVersion from information about a single version of
+-- a package.
+mkPv :: String -> Version -> Bool -> HaddockInfo -> PkgVersion
+mkPv nm v ex Nothing = PkgVersion nm Nothing v ex Nothing
+mkPv nm v ex (Just (hp, syn)) = PkgVersion nm (Just syn) v ex (Just hp)
+
+-- | Render the HTML for a list of versions of (we presume) the same
+-- package.
+pvsHtml :: [PkgVersion] -> Html
+pvsHtml pvs = pvHeader (head pvs) +++ spaceHtml +++ pvVersions pvs +++
+                pvSyn pvs
+
+-- | Render the "header" part of some package's HTML: name (with link
+-- to default version of local docs if available) and hackage link.
+pvHeader :: PkgVersion -> [Html]
+pvHeader pv = [maybeURL nme (pvHaddocks pv)
+              ,spaceHtml
+              ,anchor ![href $ hackagePath pv] << extLinkArrow
+              ]
+  where nme = if not (pvExposed pv) then "(" ++ nm ++ ")" else nm
+        nm = pvName pv
+
+-- | Render HTML linking to the various versions of a package
+-- installed, listed by version number only (name is implicit).
+pvVersions :: [PkgVersion] -> Html
+pvVersions [_] = noHtml -- Don't bother if there's only one version.
+pvVersions pvs = stringToHtml "[" +++
+                  intersperse comma (map pvOneVer pvs) +++
+                  stringToHtml "]"
+  where pvOneVer pv = maybeURL (showVersion $ pvVersion pv) (pvHaddocks pv)
+
+-- | Render the synopsis of a package, if present in any of its versions.
+pvSyn :: [PkgVersion] -> Html
+pvSyn = maybe noHtml (\x -> mdash +++ stringToHtml x) . msum . map pvSynopsis
+
+-- | Render a URL if there's a path; otherwise, just render some text.
+-- (Useful in cases where a package is installed but no documentation
+-- was found: you'll still get the hackage link.)
+maybeURL :: String -> Maybe String -> Html
+maybeURL nm Nothing = stringToHtml nm
+maybeURL nm (Just path) = anchor ![href $ joinPath [path, "index.html"]] << nm
+
+-- | Compute the URL to a package's page on hackage.
+hackagePath :: PkgVersion -> String
+hackagePath pv = "http://hackage.haskell.org/package/" ++ pvTag
+  where pvTag = pvName pv ++ "-" ++ showVersion (pvVersion pv)
+
+-- Some primitives.
+
+bull, comma, extLinkArrow, mdash :: Html
+bull = primHtml " &bull; "
+comma = stringToHtml ", "
+extLinkArrow = primHtml "&#x2b08;"
+mdash = primHtml " &mdash; "
diff --git a/src/Distribution/GhcPkgList.hs b/src/Distribution/GhcPkgList.hs
new file mode 100644
--- /dev/null
+++ b/src/Distribution/GhcPkgList.hs
@@ -0,0 +1,139 @@
+-- | Get contents of installed packages by querying "ghc-pkg" via
+-- Cabal.
+
+module Distribution.GhcPkgList (
+  PackageMap,
+  VersionMap,
+  VersionInfo,
+  HaddockInfo,
+  installedPackages
+) where
+
+import Control.Arrow
+import Data.List
+import Data.List.Utils (addToAL)
+import Data.Maybe (fromMaybe)
+import qualified Distribution.InstalledPackageInfo as I
+import qualified Distribution.Package as P
+import Distribution.Simple.Compiler (PackageDB(GlobalPackageDB,
+                                               UserPackageDB))
+import Distribution.Simple.GHC (getInstalledPackages)
+import Distribution.Simple.PackageIndex (PackageIndex, allPackagesByName)
+import Distribution.Simple.Program (ghcProgram, ghcPkgProgram)
+import Distribution.Simple.Program.Db (addKnownPrograms,
+                                       configureAllKnownPrograms,
+                                       emptyProgramDb)
+import Distribution.Verbosity (normal)
+import Distribution.Version (Version)
+import System.FilePath
+import Text.HTML.TagSoup
+
+-- XXX Slightly nasty that we import this here, as otherwise this
+-- module stands pleasantly independent of the rest of docidx.
+import Distribution.DocIdx.Common
+
+-- | A package map maps package names to information about the
+-- versions installed.
+type PackageMap a = [(String, VersionMap a)]
+
+-- | A version map maps version numbers to information about the
+-- various installations of that version.
+type VersionMap a = [(Version, [VersionInfo a])]
+
+-- | Information about a particular version of a package; at a minimum
+-- this is whether it is exposed; other information may be attached
+-- (in particular we will attach paths to Haddock docs and, later,
+-- whether those docs exist and are readable, and their synopses).
+type VersionInfo a = (Bool, [a])
+
+-- | Cabal tells us about locations of Haddock docs, but they might
+-- not actually exist or be readable.  If they do, we're interested in
+-- their path and the package synopsis extracted from the title tag of
+-- their index.html.
+type HaddockInfo = Maybe (FilePath, String)
+
+-- | Get exposure/haddock information about all versions of all
+-- installed packages.
+installedPackages :: IO (PackageMap HaddockInfo)
+installedPackages = fmap groupPackages listInstalledPackages >>= checkHaddocks
+
+-- Nothing from here down is exposed.
+
+-- | Get the list of installed packages, via Cabal's existing
+-- machinery.
+listInstalledPackages :: IO PackageIndex
+listInstalledPackages =
+  let pdb = addKnownPrograms [ghcProgram,ghcPkgProgram] emptyProgramDb
+  in configureAllKnownPrograms normal pdb >>=
+         getInstalledPackages normal [GlobalPackageDB, UserPackageDB]
+
+-- | Group installed package information together by package name and
+-- version number.  At this stage all we know about the Haddock docs
+-- are where Cabal says they are (not whether they exist), so
+-- PackageMap is parameterised over such paths.
+groupPackages :: PackageIndex -> PackageMap FilePath
+groupPackages = foldr groupPackages' [] . allPackagesByName
+
+groupPackages' :: [I.InstalledPackageInfo] -> PackageMap FilePath ->
+                  PackageMap FilePath
+groupPackages' ps pm = foldr groupPackages'' pm ps
+
+groupPackages'' :: I.InstalledPackageInfo -> PackageMap FilePath ->
+                   PackageMap FilePath
+groupPackages'' ipi pm =
+  addToAL pm nm $ addToVersionMap vs' ver (ex, had)
+    where vs' = fromMaybe [] (nm `lookup` pm)
+          pid = I.sourcePackageId ipi
+          (P.PackageName nm) = P.pkgName pid
+          ver = P.pkgVersion pid
+          ex = I.exposed ipi
+          had = I.haddockHTMLs ipi
+
+addToVersionMap :: Eq a => VersionMap a -> Version -> VersionInfo a ->
+                   VersionMap a
+addToVersionMap vm v vi = addToAL vm v xs'
+  where xs' = case v `lookup` vm of
+                -- No duplicates please.
+                Just xs -> if vi `elem` xs then xs else xs ++ [vi]
+                Nothing -> [vi]
+
+-- Checking existence of Haddock docs, and reading synopses from them.
+
+-- | Given a PackageMap over paths to Haddock directories, turn it
+-- into one over HaddockInfo values (checking if the Haddocks exist
+-- and are readable, and if so, extracting the package synopsis from
+-- each).
+checkHaddocks :: PackageMap FilePath -> IO (PackageMap HaddockInfo)
+checkHaddocks = pmMegaLift checkHaddock
+
+-- Try to read the index.html file of some Haddock directory, and
+-- extract the package synopsis.
+checkHaddock :: FilePath -> IO HaddockInfo
+checkHaddock hp = do
+  result <- tryReadFile $ joinPath [hp, "index.html"]
+  case result of
+    Just x -> return $ Just (hp, parsePackageSynopsis x)
+    Nothing -> return Nothing
+
+-- | Parses a Haddock index.html to find the package's synopsis (in
+-- the title tag).
+parsePackageSynopsis :: String -> String
+parsePackageSynopsis s = if null w then t else unwords $ tail w
+  where w = words t
+        t = findTitleTag $ canonicalizeTags $ parseTags s
+        findTitleTag ts = maybe "" (fromTagText . snd) $ seekT ts
+        seekT ts = find (isTagOpenName "title" . fst) (zip ts $ tail ts)
+
+-- | Lift a function on the second element of a VersionInfo into a
+-- function on a PackageMap.  Sorry this is so wild - it's just
+-- digging deep into the (fairly repetitive) PackageMap structure; I
+-- expect that if I understood, say, Control.Arrow, better, this could
+-- be written more sensibly.
+pmMegaLift :: (a -> IO b) -> PackageMap a -> IO (PackageMap b)
+pmMegaLift = mapSndM . mapSndM . mapSndM . mapM
+  where mapSndM = mapM . sndM
+        -- | Weird monadic second-ish combinator.  Modified from answers on
+        -- http://stackoverflow.com/questions/3998133/
+        --   does-this-simple-haskell-function-already-have-a-well-known-name
+        sndM :: (Functor m, Monad m) => (a -> m b) -> (c, a) -> m (c, b)
+        sndM f = uncurry (fmap . (,)) . second f
