diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# Change Log for neuron
-
-## 0.4.0.0
-
-- Notable changes
-  - More convenient links; `<foobar>` instead of `[foobar](z:/)` (#59)
-  - Hierarchical tags, with tag pattern in zquery (#115)
-  - Change default ID generation to use random hash (#151)
-    - Add `date` field to Markdown metadata
-  - Added `neuron query` to query the Zettelkasten and get JSON output (#42)
-- CLI argument parsing revamp
-  - Zettelkasten directory is now provided via the `-d` argument.
-    - Its default, `~/zettelkasten`, is used when not specified.
-    - This directory must exist, otherwise neuron will error out.
-  - The output directory is now moved to `.neuron/output`
-  - `neuron ... rib serve` is now `neuron rib -wS`.
-- CLI changes:
-  - Full text search: `neuron search --full-text`
-  - #43: Add `neuron search -e` to open the matching zettel in $EDITOR
-  - Allow customizing output directory
-  - Added `neuron open` to open the locally generated Zettelkasten site.
-  - #107: Add full path to the zettel in `neuron query` JSON
-- Web interface:
-  - Custom themes for web interface
-  - Display all backlinks to a zettel (even those not in category tree) (#34)
-  - Simplified link style (#151)
-  - Client-side web search (#90)
-  - Add JSON-LD breadcrumbs (#147)
-  - Add query to display tag tree (#121)
-  - Custom alias redirects
-  - Tags are restyled and positioned below
-  - Produce compact CSS in HTML head
-  - #24: zquery is displayed in HTML view.
-  - #100: Tables are styled nicely using Semantic UI
-- Bug fixes
-  - Fix regression in neuron library use
-  - #130: Handle links inside blockquotes
-
-## 0.2.0.0
-
-- Initial public release
diff --git a/README.md b/README.md
deleted file mode 100644
--- a/README.md
+++ /dev/null
@@ -1,44 +0,0 @@
-<img width="10%" src="./assets/logo.svg">
-
-# neuron
-
-[![BSD3](https://img.shields.io/badge/License-BSD-blue.svg)](https://en.wikipedia.org/wiki/BSD_License)
-[![built with nix](https://img.shields.io/badge/Built_With-Nix-5277C3.svg?logo=nixos&labelColor=73C3D5)](https://builtwithnix.org)
-[![Zulip chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://funprog.zulipchat.com/#narrow/stream/231929-Neuron)
-
-neuron is a system for managing your plain-text [Zettelkasten](https://neuron.zettel.page/2011401.html) notes. 
-
-**Features**
-
-- Extended Markdown for easy linking between zettels
-- Web interface (auto generated static site)
-- Graph view of zettels (organic category tree)
-- CLI for creating new zettels with automatic ID
-
-## Getting started
-
-See [neuron.zettel.page](https://neuron.zettel.page/) for the full guide to installing and using neuron.
-
-## Developing
-
-When modifying `src/Neuron`, use ghcid as instructed as follows to monitor compile errors:
-
-```bash
-nix-shell --run ghcid
-```
-
-You can test your changes by running it on the `./guide` (or any) zettelkasten as follows:
-
-```bash
-bin/run -d ./guide rib -wS
-```
-
-This command will also automatically recompile and restart when you change any of the Haskell source files.
-
-### Running tests
-
-Unit tests can be run via ghcid as follows:
-
-```
-bin/test
-```
diff --git a/neuron.cabal b/neuron.cabal
--- a/neuron.cabal
+++ b/neuron.cabal
@@ -1,8 +1,8 @@
 cabal-version: 2.4
 name: neuron
 -- This version must be in sync with what's in Default.dhall
-version: 0.4.0.0
-license: BSD-3-Clause
+version: 0.6.0.0
+license: AGPL-3.0-only
 copyright: 2020 Sridhar Ratnakumar
 maintainer: srid@srid.ca
 author: Sridhar Ratnakumar
@@ -10,16 +10,12 @@
 homepage: https://neuron.zettel.page
 bug-reports: https://github.com/srid/neuron/issues
 synopsis:
-  Haskell meets Zettelkasten, for your plain-text delight.
+  Future-proof system for plain-text notes.
 description:
-  neuron is a system for managing your plain-text Zettelkasten notes.
-extra-source-files:
-  README.md
-  CHANGELOG.md
+  neuron is a future-proof system for managing your plain-text Zettelkasten notes.
 data-files:
   src-js/search.js
   src-bash/neuron-search
-  src-dhall/Config/*.dhall
 
 source-repository head
     type: git
@@ -50,17 +46,61 @@
     parser-combinators,
     containers,
     filepattern,
-    mmark,
-    megaparsec
+    megaparsec,
+    commonmark ^>= 0.1.0.0,
+    commonmark-extensions ^>= 0.1.0.0,
+    commonmark-pandoc ^>= 0.1.0.0,
+    pandoc-types,
+    parsec,
+    modern-uri,
+    HsYAML,
+    aeson-gadt-th,
+    dependent-sum,
+    dependent-sum-template,
+    dependent-sum-aeson-orphans,
+    data-default,
+    reflex,
+    reflex-dom-core,
+    reflex-dom-pandoc >= 0.2.0.0,
+    clay,
+    tagged
 
 library
   import: library-common
   hs-source-dirs: src/lib
   exposed-modules:
+    Neuron.Config.Type
+    Neuron.Reader.Markdown
+    Neuron.Reader.Type
     Neuron.Zettelkasten.ID
     Neuron.Zettelkasten.Zettel
     Neuron.Zettelkasten.Zettel.Meta
+    Neuron.Zettelkasten.Zettel.Parser
+    Neuron.Zettelkasten.Connection
+    Neuron.Zettelkasten.Query
+    Neuron.Zettelkasten.Query.Graph
+    Neuron.Zettelkasten.Query.Error
+    Neuron.Zettelkasten.Query.Eval
+    Neuron.Zettelkasten.Query.Parser
+    Neuron.Zettelkasten.Query.Theme
+    Neuron.Zettelkasten.Graph
+    Neuron.Zettelkasten.Graph.Type
+    Neuron.Zettelkasten.Graph.Build
+    Neuron.Web.Route
+    Neuron.Web.Manifest
+    Neuron.Web.Common
+    Neuron.Web.Widget
+    Neuron.Web.Widget.AutoScroll
+    Neuron.Web.Widget.InvertedTree
+    Neuron.Web.Query.View
+    Neuron.Web.Zettel.View
+    Neuron.Web.Zettel.CSS
+    Neuron.Web.ZIndex
+    Neuron.Web.Theme
+    Neuron.Orphans
     Text.Megaparsec.Simple
+    Text.Pandoc.Util
+    Text.URI.Util
     Data.TagTree
     Data.PathTree
     Data.Graph.Labelled
@@ -83,17 +123,15 @@
     directory,
     exceptions,
     file-embed,
-    gitrev,
-    lucid -any,
     optparse-applicative,
     pandoc,
+    skylighting-core,
     relude,
-    rib ^>=0.10,
+    iso8601-time,
+    rib >=0.12,
     shake -any,
     time,
     text,
-    mmark,
-    mmark-ext,
     modern-uri,
     foldl,
     filepath,
@@ -103,11 +141,12 @@
     megaparsec >= 8.0,
     dependent-sum,
     dependent-sum-template,
-    aeson-gadt-th,
     data-default,
     uuid,
     shake,
-    with-utf8
+    with-utf8,
+    shower,
+    either
   if (!flag(ghcid))
     autogen-modules:
       Paths_neuron
@@ -126,32 +165,17 @@
       Neuron.CLI.Types
       Neuron.Config
       Neuron.Config.Alias
+      Neuron.Config.Orphans
       Neuron.Version
-      Neuron.Version.RepoVersion
       Neuron.Web.Generate
-      Neuron.Web.Route
-      Neuron.Web.Theme
+      Neuron.Web.Generate.Route
       Neuron.Web.View
-      Neuron.Zettelkasten.Connection
-      Neuron.Zettelkasten.Error
-      Neuron.Zettelkasten.Graph
-      Neuron.Zettelkasten.Graph.Type
-      Neuron.Zettelkasten.ID
+      Neuron.Web.StructuredData
+      Neuron.Reader
+      Neuron.Reader.Org
       Neuron.Zettelkasten.ID.Scheme
-      Neuron.Zettelkasten.Query
-      Neuron.Zettelkasten.Query.Error
-      Neuron.Zettelkasten.Query.Eval
-      Neuron.Zettelkasten.Query.Parser
-      Neuron.Zettelkasten.Query.Theme
-      Neuron.Zettelkasten.Query.View
-      Neuron.Zettelkasten.Zettel
-      Neuron.Zettelkasten.Zettel.Meta
       Paths_neuron
-      Text.MMark.Extension.ReplaceLink
-      Text.MMark.Extension.SetTableClass
-      Text.MMark.MarkdownLink
       Text.Megaparsec.Simple
-      Text.URI.Util
 
 test-suite neuron-test
   import: app-common
@@ -178,6 +202,8 @@
 
 -- The executable stanza should always be at the end. The other project will
 -- strip it to avoid non-core dependencies.
+-- To profile: ghc-options: -fexternal-interpreter -prof -fprof-auto -rtsopts
 executable neuron
   import: app-common
   main-is: Main.hs
+  ghc-options: -threaded -rtsopts -with-rtsopts=-N
diff --git a/src-dhall/Config/Default.dhall b/src-dhall/Config/Default.dhall
deleted file mode 100644
--- a/src-dhall/Config/Default.dhall
+++ /dev/null
@@ -1,17 +0,0 @@
-{ siteTitle =
-    "My Zettelkasten"
-, author =
-    None Text
-, siteBaseUrl =
-    None Text
-, editUrl =
-    None Text
-, theme =
-    "blue"
-, aliases =
-    [] : List Text
-, mathJaxSupport =
-    True
-, minVersion =
-    "0.4"
-}
diff --git a/src-dhall/Config/Type.dhall b/src-dhall/Config/Type.dhall
deleted file mode 100644
--- a/src-dhall/Config/Type.dhall
+++ /dev/null
@@ -1,17 +0,0 @@
-{ siteTitle :
-    Text
-, author :
-    Optional Text
-, siteBaseUrl :
-    Optional Text
-, editUrl :
-    Optional Text
-, theme :
-    Text
-, aliases :
-    List Text
-, mathJaxSupport :
-    Bool
-, minVersion :
-    Text
-}
diff --git a/src-js/search.js b/src-js/search.js
--- a/src-js/search.js
+++ b/src-js/search.js
@@ -1,5 +1,5 @@
 let search = "",
-    selectedTags = [];
+  selectedTags = [];
 
 let searchResults = document.getElementById("search-results"); // ul element
 let searchInput = document.getElementById("search-input");
@@ -7,24 +7,20 @@
 
 // Create a zettel link from a zettel object
 function makeZettelLink(zettel) {
+  let container = document.createElement("span");
+  container.classList.add("zettel-link-container");
+
   let zettelLink = document.createElement("span");
   zettelLink.classList.add("zettel-link");
 
-  let idLink = document.createElement("span");
-  idLink.classList.add("zettel-link-idlink");
-
   let actualLink = document.createElement("a");
   actualLink.href = zettel.id + ".html";
-  actualLink.innerHTML = zettel.id;
-  idLink.appendChild(actualLink);
-  zettelLink.appendChild(idLink);
+  actualLink.innerHTML = zettel.title;
 
-  let linkTitle = document.createElement("span");
-  linkTitle.classList.add("zettel-link-title");
-  linkTitle.innerHTML = zettel.title;
-  zettelLink.appendChild(linkTitle);
+  zettelLink.appendChild(actualLink);
+  container.appendChild(zettelLink);
 
-  return zettelLink;
+  return container;
 }
 
 // Renders the results as a list
@@ -91,7 +87,7 @@
 initializeSearchFromURL();
 
 $('#search-input').on("change paste keyup", runSearch)
-                  .focus();
+  .focus();
 
 $('#search-tags').dropdown({
   onChange: (value) => {
diff --git a/src/app/Data/Structured/Breadcrumb.hs b/src/app/Data/Structured/Breadcrumb.hs
--- a/src/app/Data/Structured/Breadcrumb.hs
+++ b/src/app/Data/Structured/Breadcrumb.hs
@@ -13,7 +13,7 @@
 
 import Data.Aeson
 import Data.Tree
-import Lucid
+import Reflex.Dom.Core hiding (mapMaybe)
 import Relude
 import Text.URI (URI)
 import qualified Text.URI as URI
@@ -27,11 +27,9 @@
 newtype Breadcrumb = Breadcrumb {unBreadcrumb :: NonEmpty Item}
   deriving (Eq, Show, Generic)
 
-instance ToHtml [Breadcrumb] where
-  toHtmlRaw = toHtml
-  toHtml bs =
-    script_ [type_ "application/ld+json"] $
-      encode bs
+renderBreadcrumbs :: DomBuilder t m => [Breadcrumb] -> m ()
+renderBreadcrumbs bs =
+  elAttr "script" ("type" =: "application/ld+json") $ text $ decodeUtf8 $ encode bs
 
 instance ToJSON Breadcrumb where
   toJSON (Breadcrumb (toList -> crumbs)) =
@@ -60,38 +58,3 @@
       [] -> [[parent]]
       childPaths ->
         fmap (parent :) `concatMap` childPaths
-{-
-    [{
-      "@context": "https://schema.org",
-      "@type": "BreadcrumbList",
-      "itemListElement": [{
-        "@type": "ListItem",
-        "position": 1,
-        "name": "Books",
-        "item": "https://example.com/books"
-      },{
-        "@type": "ListItem",
-        "position": 2,
-        "name": "Science Fiction",
-        "item": "https://example.com/books/sciencefiction"
-      },{
-        "@type": "ListItem",
-        "position": 3,
-        "name": "Award Winners"
-      }]
-    },
-    {
-      "@context": "https://schema.org",
-      "@type": "BreadcrumbList",
-      "itemListElement": [{
-        "@type": "ListItem",
-        "position": 1,
-        "name": "Literature",
-        "item": "https://example.com/literature"
-      },{
-        "@type": "ListItem",
-        "position": 2,
-        "name": "Award Winners"
-      }]
-    }]
--}
diff --git a/src/app/Main.hs b/src/app/Main.hs
--- a/src/app/Main.hs
+++ b/src/app/Main.hs
@@ -1,72 +1,41 @@
+{-# LANGUAGE DataKinds #-}
 {-# LANGUAGE GADTs #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 
 module Main where
 
-import Clay hiding (s, style, type_)
-import qualified Clay as C
+import Control.Monad.Reader
 import Development.Shake
-import Lucid
 import Main.Utf8
 import Neuron.CLI (run)
-import Neuron.Config (Config)
-import qualified Neuron.Config as Config
+import Neuron.Config.Type (Config)
+import Neuron.Version (neuronVersion)
 import Neuron.Web.Generate (generateSite)
-import Neuron.Web.Route (Route (..))
-import Neuron.Web.View (renderRouteBody, renderRouteHead, style)
+import Neuron.Web.Generate.Route (staticRouteConfig)
+import qualified Neuron.Web.Manifest as Manifest
+import Neuron.Web.Route (Route (..), runNeuronWeb)
+import Neuron.Web.View (renderRoutePage)
+import Neuron.Zettelkasten.Graph.Type (ZettelGraph)
+import Reflex.Dom.Core
 import Relude
 import qualified Rib
-import Rib.Extra.CSS (googleFonts, stylesheet)
 
 main :: IO ()
 main = withUtf8 $ run generateMainSite
 
-generateMainSite :: Action ()
-generateMainSite = do
-  Rib.buildStaticFiles ["static/**"]
-  config <- Config.getConfig
-  let writeHtmlRoute :: Route g a -> (g, a) -> Action ()
-      writeHtmlRoute r = Rib.writeRoute r . Lucid.renderText . renderPage config r
+generateMainSite :: Config -> Action ()
+generateMainSite config = do
+  notesDir <- Rib.ribInputDir
+  Rib.buildStaticFiles ["static/**", ".nojekyll"]
+  manifest <- fmap Manifest.mkManifest $ getDirectoryFiles notesDir Manifest.manifestPatterns
+  let writeHtmlRoute :: Route a -> (ZettelGraph, a) -> Action ()
+      writeHtmlRoute r x = do
+        html <- liftIO $ fmap snd $ renderStatic $ do
+          runNeuronWeb staticRouteConfig $
+            renderRoutePage neuronVersion config manifest r x
+        -- FIXME: Make rib take bytestrings
+        Rib.writeRoute r $ decodeUtf8 @Text html
   void $ generateSite config writeHtmlRoute
-
-renderPage :: Config -> Route g a -> (g, a) -> Html ()
-renderPage config r val = html_ [lang_ "en"] $ do
-  head_ $ do
-    renderRouteHead config r val
-    case r of
-      Route_Redirect _ ->
-        mempty
-      _ -> do
-        stylesheet "https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.css"
-        stylesheet "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.11.2/css/all.min.css"
-        style_ [type_ "text/css"] $ C.renderWith C.compact [] $ mainStyle config
-        googleFonts [headerFont, bodyFont, monoFont]
-        when (Config.mathJaxSupport config) $
-          with (script_ mempty) [id_ "MathJax-script", src_ "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js", async_ ""]
-  body_
-    $ div_ [class_ "ui text container", id_ "thesite"]
-    $ renderRouteBody config r val
-
-headerFont :: Text
-headerFont = "Oswald"
-
-bodyFont :: Text
-bodyFont = "Open Sans"
-
-monoFont :: Text
-monoFont = "Roboto Mono"
-
-mainStyle :: Config -> Css
-mainStyle cfg = "div#thesite" ? do
-  C.fontFamily [bodyFont] [C.serif]
-  C.paddingTop $ em 1
-  C.paddingBottom $ em 1
-  "p" ? do
-    C.lineHeight $ pct 150
-  "h1, h2, h3, h4, h5, h6, .ui.header" ? do
-    C.fontFamily [headerFont] [C.sansSerif]
-  "img" ? do
-    C.maxWidth $ pct 100 -- Prevents large images from overflowing beyond zettel borders
-  style cfg
diff --git a/src/app/Neuron/CLI.hs b/src/app/Neuron/CLI.hs
--- a/src/app/Neuron/CLI.hs
+++ b/src/app/Neuron/CLI.hs
@@ -1,4 +1,5 @@
 {-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RankNTypes #-}
 {-# LANGUAGE RecordWildCards #-}
@@ -17,7 +18,10 @@
 import Neuron.CLI.New (newZettelFile)
 import Neuron.CLI.Rib
 import Neuron.CLI.Search (interactiveSearch)
+import Neuron.Config (getConfig)
+import Neuron.Config.Type (Config)
 import qualified Neuron.Version as Version
+import qualified Neuron.Web.Generate as Gen
 import qualified Neuron.Zettelkasten.Graph as G
 import qualified Neuron.Zettelkasten.Query as Q
 import Options.Applicative
@@ -28,11 +32,9 @@
 import System.Info (os)
 import System.Posix.Process
 
-run :: Action () -> IO ()
+run :: (Config -> Action ()) -> IO ()
 run act = do
-  today <- utctDay <$> liftIO getCurrentTime
-  defaultNotesDir <- (</> "zettelkasten") <$> getHomeDirectory
-  let cliParser = commandParser defaultNotesDir today
+  cliParser <- commandParser <$> defaultNotesDir <*> today
   app <-
     execParser $
       info
@@ -42,27 +44,39 @@
   where
     versionOption =
       infoOption
-        (toString Version.neuronVersionFull)
+        (toString Version.neuronVersion)
         (long "version" <> help "Show version")
+    defaultNotesDir =
+      (</> "zettelkasten") <$> getHomeDirectory
+    today = do
+      tz <- getCurrentTimeZone
+      localDay . utcToLocalTime tz <$> liftIO getCurrentTime
 
-runWith :: Action () -> App -> IO ()
+runWith :: (Config -> Action ()) -> App -> IO ()
 runWith act App {..} =
   case cmd of
     Rib ribCfg ->
-      runRib act notesDir ribCfg
+      runRib (act =<< getConfig) notesDir ribCfg
     New newCommand ->
       runRibOnceQuietly notesDir $ do
-        newZettelFile newCommand
+        newZettelFile newCommand =<< getConfig
     Open ->
       runRibOnceQuietly notesDir $ do
         indexHtmlPath <- fmap (</> "index.html") Rib.ribOutputDir
         putStrLn indexHtmlPath
         let opener = if os == "darwin" then "open" else "xdg-open"
         liftIO $ executeFile opener True [indexHtmlPath] Nothing
-    Query someQ ->
+    Query eSomeQ ->
       runRibOnceQuietly notesDir $ do
-        withSome someQ $ \q -> do
-          result <- flip Q.runQuery q <$> G.loadZettels
-          putLTextLn $ Aeson.encodeToLazyText $ Q.queryResultJson notesDir q result
+        (graph, _, errors) <- Gen.loadZettelkasten =<< getConfig
+        case eSomeQ of
+          Left someQ ->
+            withSome someQ $ \q -> do
+              let result = Q.runZettelQuery (G.getZettels graph) q
+              putLTextLn $ Aeson.encodeToLazyText $ Q.zettelQueryResultJson q result errors
+          Right someQ ->
+            withSome someQ $ \q -> do
+              let result = Q.runGraphQuery graph q
+              putLTextLn $ Aeson.encodeToLazyText $ Q.graphQueryResultJson q result errors
     Search searchCmd ->
       interactiveSearch notesDir searchCmd
diff --git a/src/app/Neuron/CLI/New.hs b/src/app/Neuron/CLI/New.hs
--- a/src/app/Neuron/CLI/New.hs
+++ b/src/app/Neuron/CLI/New.hs
@@ -15,15 +15,20 @@
 import Data.Some
 import Data.Text (strip)
 import qualified Data.Text as T
+import Data.Time
 import Development.Shake (Action)
 import Neuron.CLI.Types
-import qualified Neuron.Zettelkasten.Graph as G
-import Neuron.Zettelkasten.ID (ZettelID, zettelIDSourceFileName)
+import Neuron.Config.Type (Config (..), getZettelFormats)
+import Neuron.Reader.Type (ZettelFormat (..))
+import Neuron.Web.Generate as Gen
+import Neuron.Zettelkasten.ID (zettelIDSourceFileName)
 import qualified Neuron.Zettelkasten.ID.Scheme as IDScheme
 import Neuron.Zettelkasten.Zettel (zettelID)
+import Neuron.Zettelkasten.Zettel.Meta (formatZettelDate)
 import Options.Applicative
 import Relude
 import qualified Rib
+import System.Directory (setCurrentDirectory)
 import System.FilePath
 import qualified System.Posix.Env as Env
 import System.Posix.Process
@@ -31,51 +36,75 @@
 -- | Create a new zettel file and open it in editor if requested
 --
 -- As well as print the path to the created file.
-newZettelFile :: NewCommand -> Action ()
-newZettelFile NewCommand {..} = do
-  zettels <- G.loadZettels
+newZettelFile :: NewCommand -> Config -> Action ()
+newZettelFile NewCommand {..} config = do
+  (_, zettels, _) <- Gen.loadZettelkasten config
   mzid <- withSome idScheme $ \scheme -> do
     val <- liftIO $ IDScheme.genVal scheme
-    pure $ IDScheme.nextAvailableZettelID (Set.fromList $ fmap zettelID zettels) val scheme
+    pure $
+      IDScheme.nextAvailableZettelID
+        (Set.fromList $ fmap (either zettelID zettelID) zettels)
+        val
+        scheme
   case mzid of
     Left e -> die $ show e
     Right zid -> do
-      path <- zettelPath zid
+      notesDir <- Rib.ribInputDir
+      defaultFormat <- head <$> getZettelFormats config
+      let zettelFormat = fromMaybe defaultFormat format
+          zettelFile = zettelIDSourceFileName zid zettelFormat
       liftIO $ do
-        fileAction :: FilePath -> IO () <-
+        fileAction :: FilePath -> FilePath -> IO () <-
           bool (pure showAction) mkEditActionFromEnv edit
-        writeFileText path $
-          T.intercalate
-            "\n"
-            [ "---",
-              "title: " <> title,
-              "date: " <> show day,
-              "---",
-              "",
-              ""
-            ]
-        fileAction path
+        writeFileText (notesDir </> zettelFile) $ defaultZettelContent zettelFormat day title
+        fileAction notesDir zettelFile
   where
-    mkEditActionFromEnv :: IO (FilePath -> IO ())
+    mkEditActionFromEnv :: IO (FilePath -> FilePath -> IO ())
     mkEditActionFromEnv =
       getEnvNonEmpty "EDITOR" >>= \case
         Nothing ->
           die "\n-e option can only be used with EDITOR environment variable set"
-        Just editorExe ->
-          pure $ editAction editorExe
-    editAction editorExe path = do
-      -- Show it first in case the editor launch fails
-      showAction path
-      executeFile editorExe True [path] Nothing
-    showAction =
-      putStrLn
+        Just editorCli ->
+          pure $ editAction editorCli
+    editAction editorCli notesDir zettelFile = do
+      -- Show the path first, in case the editor launch fails
+      showAction notesDir zettelFile
+      setCurrentDirectory notesDir
+      executeShellCommand $ editorCli <> " " <> zettelFile
+    showAction notesDir zettelFile =
+      putStrLn $ notesDir </> zettelFile
+    -- Like `executeFile` but takes a shell command.
+    executeShellCommand cmd =
+      executeFile "bash" True ["-c", cmd] Nothing
     getEnvNonEmpty name =
       Env.getEnv name >>= \case
         Nothing -> pure Nothing
         Just (toString . strip . toText -> v) ->
           if null v then pure Nothing else pure (Just v)
 
-zettelPath :: ZettelID -> Action FilePath
-zettelPath zid = do
-  notesDir <- Rib.ribInputDir
-  pure $ notesDir </> zettelIDSourceFileName zid
+-- TODO use configurable template files?
+defaultZettelContent :: ZettelFormat -> Day -> Maybe Text -> Text
+defaultZettelContent format day mtitle = case format of
+  ZettelFormat_Markdown ->
+    T.intercalate
+      "\n"
+      [ "---",
+        "date: " <> date,
+        "---",
+        "",
+        "# " <> title,
+        "\n"
+      ]
+  ZettelFormat_Org ->
+    T.intercalate
+      "\n"
+      [ "* " <> title,
+        "    :PROPERTIES:",
+        "    :Date: " <> date,
+        "    :END:",
+        "\n"
+      ]
+  where
+    date = formatZettelDate day
+    defaultTitleName = "Zettel created on " <> date
+    title = maybe defaultTitleName T.strip mtitle
diff --git a/src/app/Neuron/CLI/Search.hs b/src/app/Neuron/CLI/Search.hs
--- a/src/app/Neuron/CLI/Search.hs
+++ b/src/app/Neuron/CLI/Search.hs
@@ -23,7 +23,7 @@
 searchScriptArgs SearchCommand {..} =
   let searchByArgs =
         case searchBy of
-          SearchByTitle -> ["title: ", "3"]
+          SearchByTitle -> ["(^# )|(^title: )", "2"]
           SearchByContent -> ["", "2"]
       editArg =
         bool "echo" "$EDITOR" searchEdit
diff --git a/src/app/Neuron/CLI/Types.hs b/src/app/Neuron/CLI/Types.hs
--- a/src/app/Neuron/CLI/Types.hs
+++ b/src/app/Neuron/CLI/Types.hs
@@ -20,12 +20,16 @@
 import Data.Default (def)
 import Data.Some
 import Data.TagTree (mkTagPattern)
-import qualified Data.Text as T
 import Data.Time
+import Neuron.Reader.Type (ZettelFormat)
+import qualified Neuron.Zettelkasten.Connection as C
 import Neuron.Zettelkasten.ID (ZettelID, parseZettelID')
 import Neuron.Zettelkasten.ID.Scheme (IDScheme (..))
-import Neuron.Zettelkasten.Query as Q
+import qualified Neuron.Zettelkasten.Query.Error as Q
+import Neuron.Zettelkasten.Query.Graph as Q
 import qualified Neuron.Zettelkasten.Query.Parser as Q
+import Neuron.Zettelkasten.Zettel as Q
+import Neuron.Zettelkasten.Zettel.Meta (parseZettelDate)
 import Options.Applicative
 import Relude
 import qualified Rib.Cli
@@ -37,7 +41,8 @@
   }
 
 data NewCommand = NewCommand
-  { title :: Text,
+  { title :: Maybe Text,
+    format :: Maybe ZettelFormat,
     day :: Day,
     idScheme :: Some IDScheme,
     edit :: Bool
@@ -63,7 +68,7 @@
   | -- | Search a zettel by title
     Search SearchCommand
   | -- | Run a query against the Zettelkasten
-    Query (Some Q.Query)
+    Query (Either (Some Q.ZettelQuery) (Some Q.GraphQuery))
   | -- | Delegate to Rib's command parser
     Rib RibConfig
 
@@ -98,7 +103,14 @@
             command "rib" $ info ribCommand $ progDesc "Generate static site via rib"
           ]
     newCommand = do
-      title <- argument nonEmptyTextReader (metavar "TITLE" <> help "Title of the new Zettel")
+      title <- optional $ strArgument (metavar "TITLE" <> help "Title of the new Zettel")
+      format <-
+        optional
+          $ option auto
+          $ metavar "FORMAT"
+            <> short 'f'
+            <> long "format"
+            <> help "The document format of the new zettel"
       edit <- switch (long "edit" <> short 'e' <> help "Open the newly-created zettel in $EDITOR")
       day <-
         option dayReader $
@@ -118,14 +130,41 @@
           <|> fmap
             (const . Some . IDSchemeCustom)
             (option str (long "id" <> help "Use a custom ID" <> metavar "IDNAME"))
-      pure $ New $ NewCommand title day (idSchemeF day) edit
+      pure $ New $ NewCommand title format day (idSchemeF day) edit
     openCommand =
       pure Open
     queryCommand =
       fmap Query $
-        fmap (Some . flip Q.Query_ZettelByID Nothing) (option zettelIDReader (long "id"))
-          <|> fmap (\x -> Some $ Q.Query_ZettelsByTag x Nothing def) (many (mkTagPattern <$> option str (long "tag" <> short 't')))
-          <|> option queryReader (long "uri" <> short 'u')
+        ( fmap
+            Left
+            ( fmap (Some . flip Q.ZettelQuery_ZettelByID Nothing) (option zettelIDReader (long "id"))
+                <|> fmap (\x -> Some $ Q.ZettelQuery_ZettelsByTag x Nothing def) (many (mkTagPattern <$> option str (long "tag" <> short 't')))
+                <|> option queryReader (long "uri" <> short 'u')
+            )
+            <|> fmap
+              Right
+              (fmap (const $ Some $ Q.GraphQuery_Id) $ switch (long "graph" <> help "Get the entire zettelkasten graph as JSON"))
+            <|> fmap
+              Right
+              ( fmap (Some . Q.GraphQuery_BacklinksOf Nothing) $
+                  option
+                    zettelIDReader
+                    ( long "backlinks-of"
+                        <> help "Get backlinks to the given zettel ID"
+                        <> metavar "ID"
+                    )
+              )
+            <|> fmap
+              Right
+              ( fmap (Some . Q.GraphQuery_BacklinksOf (Just C.Folgezettel)) $
+                  option
+                    zettelIDReader
+                    ( long "uplinks-of"
+                        <> help "Get uplinks to the given zettel ID"
+                        <> metavar "ID"
+                    )
+              )
+        )
     searchCommand = do
       searchBy <-
         bool SearchByTitle SearchByContent
@@ -148,20 +187,13 @@
     zettelIDReader :: ReadM ZettelID
     zettelIDReader =
       eitherReader $ first show . parseZettelID' . toText
-    queryReader :: ReadM (Some Q.Query)
+    queryReader :: ReadM (Some Q.ZettelQuery)
     queryReader =
       eitherReader $ \(toText -> s) -> case URI.mkURI s of
         Right uri ->
-          either (Left . show) (maybe (Left "Unsupported query") Right) $ Q.queryFromURI uri
+          either (Left . toString . Q.showQueryParseError) (maybe (Left "Unsupported query") Right) $ Q.queryFromURI uri
         Left e ->
           Left $ displayException e
-    nonEmptyTextReader :: ReadM Text
-    nonEmptyTextReader =
-      eitherReader $ \(T.strip . toText -> s) ->
-        if T.null s
-          then Left "Empty text is not allowed"
-          else Right s
     dayReader :: ReadM Day
     dayReader =
-      maybeReader $
-        parseTimeM False defaultTimeLocale "%Y-%m-%d"
+      maybeReader (parseZettelDate . toText)
diff --git a/src/app/Neuron/Config.hs b/src/app/Neuron/Config.hs
--- a/src/app/Neuron/Config.hs
+++ b/src/app/Neuron/Config.hs
@@ -7,88 +7,50 @@
 {-# LANGUAGE RecordWildCards #-}
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE StandaloneDeriving #-}
-{-# LANGUAGE TemplateHaskell #-}
 {-# LANGUAGE TypeApplications #-}
 {-# LANGUAGE ViewPatterns #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 
 module Neuron.Config
-  ( Config (..),
-    configFile,
-    getConfig,
-    BaseUrlError (..),
-    getMarkdownExtensions,
+  ( getConfig,
+    parsePure,
   )
 where
 
-import Control.Monad.Except
-import Data.FileEmbed (embedFile)
+import Data.Either.Validation (validationToEither)
 import Development.Shake (Action, readFile')
-import Dhall (FromDhall)
 import qualified Dhall
-import Dhall.TH
+import Dhall (FromDhall)
+import qualified Dhall.Core
+import qualified Dhall.Parser
+import qualified Dhall.TypeCheck
+import Neuron.Config.Orphans ()
+import Neuron.Config.Type (Config, configFile, defaultConfig, mergeWithDefault)
 import Relude
 import qualified Rib
 import System.Directory
 import System.FilePath
-import qualified Text.MMark as MMark
-import qualified Text.MMark.Extension.Common as Ext
-import Text.MMark.Extension.SetTableClass (setTableClass)
 
--- | Config type for @neuron.dhall@
---
--- See <https://neuron.zettel.page/2011701.html guide> for description of the fields.
-makeHaskellTypes
-  [ SingleConstructor "Config" "Config" "./src-dhall/Config/Type.dhall"
-  ]
-
-deriving instance Generic Config
-
-deriving instance FromDhall Config
-
-data BaseUrlError
-  = BaseUrlNotAbsolute
-  deriving (Eq, Show)
-
-instance Exception BaseUrlError
-
-defaultConfig :: ByteString
-defaultConfig = $(embedFile "./src-dhall/Config/Default.dhall")
-
-configFile :: FilePath
-configFile = "neuron.dhall"
-
--- | Read the optional @neuron.dhall@ config file from the zettelksaten
+-- | Read the optional @neuron.dhall@ config file from the zettelkasten
 getConfig :: Action Config
 getConfig = do
-  inputDir <- Rib.ribInputDir
-  let configPath = inputDir </> configFile
+  configPath <- Rib.ribInputDir <&> (</> configFile)
   configVal :: Text <- liftIO (doesFileExist configPath) >>= \case
     True -> do
-      userConfig <- fmap toText $ readFile' configPath
-      -- Dhall's combine operator (`//`) allows us to merge two records,
-      -- effectively merging the record with defaults with the user record.
-      pure $ decodeUtf8 defaultConfig <> " // " <> userConfig
+      mergeWithDefault . toText <$> readFile' configPath
     False ->
-      pure $ decodeUtf8 @Text defaultConfig
-  parseConfig configVal
-
-parseConfig :: MonadIO m => Text -> m Config
-parseConfig s =
-  liftIO $ Dhall.input Dhall.auto s
+      pure defaultConfig
+  either fail pure $ parsePure configFile $ mergeWithDefault configVal
 
-getMarkdownExtensions :: Config -> [MMark.Extension]
-getMarkdownExtensions Config {..} =
-  defaultExts
-    <> bool [] [Ext.mathJax (Just '$')] mathJaxSupport
-  where
-    defaultExts :: [MMark.Extension]
-    defaultExts =
-      [ Ext.fontAwesome,
-        Ext.footnotes,
-        Ext.kbd,
-        Ext.linkTarget,
-        Ext.punctuationPrettifier,
-        Ext.skylighting,
-        setTableClass "ui celled table"
-      ]
+-- | Pure version of `Dhall.input Dhall.auto`
+--
+-- The config file cannot have imports, as that requires IO.
+parsePure :: forall a. FromDhall a => FilePath -> Text -> Either String a
+parsePure fn s = do
+  expr0 <- first show $ Dhall.Parser.exprFromText fn s
+  expr <- maybeToRight "Cannot have imports" $ traverse (const Nothing) expr0
+  void $ first show $ Dhall.TypeCheck.typeOf expr
+  first show
+    $ validationToEither
+    $ Dhall.extract @a Dhall.auto
+    $ Dhall.Core.normalize expr
diff --git a/src/app/Neuron/Config/Alias.hs b/src/app/Neuron/Config/Alias.hs
--- a/src/app/Neuron/Config/Alias.hs
+++ b/src/app/Neuron/Config/Alias.hs
@@ -4,8 +4,8 @@
 
 module Neuron.Config.Alias where
 
-import Control.Monad.Except
-import Neuron.Config
+import Control.Monad.Except (liftEither, runExcept, throwError)
+import Neuron.Config.Type
 import qualified Neuron.Zettelkasten.Graph as G
 import Neuron.Zettelkasten.Graph.Type (ZettelGraph)
 import Neuron.Zettelkasten.ID
diff --git a/src/app/Neuron/Config/Orphans.hs b/src/app/Neuron/Config/Orphans.hs
new file mode 100644
--- /dev/null
+++ b/src/app/Neuron/Config/Orphans.hs
@@ -0,0 +1,12 @@
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE StandaloneDeriving #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module Neuron.Config.Orphans where
+
+import Dhall (FromDhall)
+import Neuron.Config.Type
+
+deriving instance FromDhall Config
diff --git a/src/app/Neuron/Reader.hs b/src/app/Neuron/Reader.hs
new file mode 100644
--- /dev/null
+++ b/src/app/Neuron/Reader.hs
@@ -0,0 +1,12 @@
+{-# LANGUAGE LambdaCase #-}
+
+module Neuron.Reader where
+
+import Neuron.Reader.Markdown
+import Neuron.Reader.Org
+import Neuron.Reader.Type
+
+readerForZettelFormat :: ZettelFormat -> ZettelReader
+readerForZettelFormat = \case
+  ZettelFormat_Markdown -> parseMarkdown
+  ZettelFormat_Org -> parseOrg
diff --git a/src/app/Neuron/Reader/Org.hs b/src/app/Neuron/Reader/Org.hs
new file mode 100644
--- /dev/null
+++ b/src/app/Neuron/Reader/Org.hs
@@ -0,0 +1,46 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Reader.Org
+  ( parseOrg,
+  )
+where
+
+import qualified Data.Map as Map
+import Data.TagTree (Tag (Tag))
+import Data.Tagged
+import Data.Time.Calendar (Day)
+import Neuron.Reader.Type (ZettelParseError, ZettelReader)
+import Neuron.Zettelkasten.Zettel.Meta (Meta (..), parseZettelDate)
+import Relude
+import Relude.Extra.Map (lookup)
+import Text.Pandoc (def, runPure)
+import Text.Pandoc.Definition hiding (Meta (..))
+import Text.Pandoc.Readers.Org (readOrg)
+import Text.Pandoc.Util (getH1)
+
+parseOrg :: ZettelReader
+parseOrg _ s = do
+  doc <- first show $ runPure $ readOrg def s
+  meta <- extractMetadata doc
+  pure (meta, doc)
+
+-- | Extract metadata from the properties that are attached to the first headline
+extractMetadata :: Pandoc -> Either ZettelParseError (Maybe Meta)
+extractMetadata doc
+  | Just ((_, _, Map.fromList -> properties), _) <- getH1 doc = do
+    date <- traverse parseDate $ lookup "date" properties
+    -- title is now deprecated
+    let title = Nothing
+        tags = fmap Tag . words <$> lookup "tags" properties
+    pure $ Just Meta {..}
+  | otherwise = pure Nothing
+  where
+    parseDate :: Text -> Either ZettelParseError Day
+    parseDate date = maybeToRight (Tagged $ "Invalid date format: " <> date) $ parseZettelDate @Maybe date
diff --git a/src/app/Neuron/Version.hs b/src/app/Neuron/Version.hs
--- a/src/app/Neuron/Version.hs
+++ b/src/app/Neuron/Version.hs
@@ -4,9 +4,7 @@
 
 module Neuron.Version where
 
-import qualified Data.Text as T
 import Data.Version (parseVersion, showVersion)
-import qualified Neuron.Version.RepoVersion as RepoVersion
 import Paths_neuron (version)
 import Relude
 import Text.ParserCombinators.ReadP (readP_to_S)
@@ -14,12 +12,6 @@
 -- | Neuron cabal library version
 neuronVersion :: Text
 neuronVersion = toText $ showVersion version
-
--- | Neuron full version (cabal library version + git revision)
-neuronVersionFull :: Text
-neuronVersionFull
-  | Just gitVersion <- RepoVersion.version =  T.concat [neuronVersion, " (", gitVersion, ")"]
-  | otherwise = neuronVersion
 
 -- | Check if `neuronVersion` is older than the given version
 olderThan :: Text -> Bool
diff --git a/src/app/Neuron/Version/RepoVersion.hs b/src/app/Neuron/Version/RepoVersion.hs
deleted file mode 100644
--- a/src/app/Neuron/Version/RepoVersion.hs
+++ /dev/null
@@ -1,21 +0,0 @@
-{-# LANGUAGE TemplateHaskell #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
--- | Neuron git repo version
---
--- This module is loaded in ghcid and cabal new-repl. However, nix-build will
--- have it overwritten by the then `git describe` (see default.nix).
-module Neuron.Version.RepoVersion
-  ( version,
-  )
-where
-
-import Development.GitRev (gitDescribe, gitDirty)
-import Relude
-
-version :: Maybe Text
-version
-   | versionByGit == toText "UNKNOWN" = Nothing
-   | otherwise = Just versionByGit
- where
-   versionByGit = toText $ $(gitDescribe) <> bool "" "-dirty" $(gitDirty)
diff --git a/src/app/Neuron/Web/Generate.hs b/src/app/Neuron/Web/Generate.hs
--- a/src/app/Neuron/Web/Generate.hs
+++ b/src/app/Neuron/Web/Generate.hs
@@ -1,48 +1,160 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RankNTypes #-}
 {-# LANGUAGE RecordWildCards #-}
 {-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE TupleSections #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE ViewPatterns #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 
 -- | Main module for using neuron as a library, instead of as a CLI tool.
 module Neuron.Web.Generate
   ( generateSite,
+    loadZettelkasten,
   )
 where
 
-import Development.Shake (Action)
-import Neuron.Config (Config (..))
+import Data.FileEmbed (embedStringFile)
+import qualified Data.Map.Strict as Map
+import Data.Tagged (untag)
+import qualified Data.Text as T
+import Data.Traversable
+import Development.Shake (Action, need)
 import Neuron.Config.Alias (Alias (..), getAliases)
+import Neuron.Config.Type (Config (minVersion), getZettelFormats)
+import Neuron.Reader (readerForZettelFormat)
+import Neuron.Reader.Type (ZettelFormat, zettelFormatToExtension)
 import Neuron.Version (neuronVersion, olderThan)
+import Neuron.Web.Generate.Route ()
 import qualified Neuron.Web.Route as Z
-import qualified Neuron.Zettelkasten.Graph as G
-import qualified Neuron.Zettelkasten.Zettel as Z
+import qualified Neuron.Zettelkasten.Graph.Build as G
+import Neuron.Zettelkasten.Graph.Type (ZettelGraph)
+import Neuron.Zettelkasten.ID (ZettelID, getZettelID)
+import Neuron.Zettelkasten.Query.Error (showQueryError)
+import Neuron.Zettelkasten.Zettel
 import Options.Applicative
 import Relude
+import qualified Rib
+import Rib.Route
+import System.FilePath
 
+searchScript :: Text
+searchScript = $(embedStringFile "./src-js/search.js")
+
 -- | Generate the Zettelkasten site
 generateSite ::
   Config ->
-  (forall a. Z.Route G.ZettelGraph a -> (G.ZettelGraph, a) -> Action ()) ->
-  Action G.ZettelGraph
+  (forall a. Z.Route a -> (ZettelGraph, a) -> Action ()) ->
+  Action ZettelGraph
 generateSite config writeHtmlRoute' = do
-  when (olderThan $ minVersion config)
-    $ fail
-    $ toString
-    $ "Require neuron mininum version " <> minVersion config <> ", but your neuron version is " <> neuronVersion
-  (zettelGraph, zettels) <- G.loadZettelkasten
-  let writeHtmlRoute v r = writeHtmlRoute' r (zettelGraph, v)
+  when (olderThan $ minVersion config) $ do
+    fail $ toString $
+      "Require neuron mininum version "
+        <> minVersion config
+        <> ", but your neuron version is "
+        <> neuronVersion
+  (zettelGraph, zettelContents, errors) <- loadZettelkasten config
+  let writeHtmlRoute :: forall a. a -> Z.Route a -> Action ()
+      writeHtmlRoute v r = writeHtmlRoute' r (zettelGraph, v)
   -- Generate HTML for every zettel
-  forM_ zettels $ \(z, d) ->
-    -- TODO: Should `Zettel` not contain ZettelID?
-    -- See duplication in `renderZettel`
-    writeHtmlRoute (z, d) $ Z.Route_Zettel (Z.zettelID z)
+  forM_ zettelContents $ \val@(sansContent -> z) ->
+    writeHtmlRoute val $ Z.Route_Zettel (zettelID z)
   -- Generate the z-index
-  writeHtmlRoute () Z.Route_ZIndex
+  writeHtmlRoute errors Z.Route_ZIndex
   -- Generate search page
-  writeHtmlRoute () Z.Route_Search
+  writeHtmlRoute searchScript Z.Route_Search
   -- Write alias redirects, unless a zettel with that name exists.
   aliases <- getAliases config zettelGraph
   forM_ aliases $ \Alias {..} ->
     writeHtmlRoute targetZettel (Z.Route_Redirect aliasZettel)
+  -- Report all errors
+  forM_ (Map.toList errors) $ \(zid, err) -> do
+    reportError (Z.Route_Zettel zid) $
+      case err of
+        ZettelError_ParseError (untag -> parseErr) ->
+          parseErr :| []
+        ZettelError_QueryErrors queryErrs ->
+          showQueryError <$> queryErrs
+        ZettelError_AmbiguousFiles filePaths ->
+          ("Multiple zettels have the same ID: " <> T.intercalate ", " (fmap toText $ toList filePaths))
+            :| []
   pure zettelGraph
+
+-- | Report an error in the terminal
+reportError :: (MonadIO m, IsRoute r) => r a -> NonEmpty Text -> m ()
+reportError route errors = do
+  path <- liftIO $ routeFile route
+  putTextLn $ "E " <> toText path
+  forM_ errors $ \err ->
+    putText $ "  - " <> indentAllButFirstLine 4 err
+  where
+    indentAllButFirstLine :: Int -> Text -> Text
+    indentAllButFirstLine n = unlines . go . lines
+      where
+        go [] = []
+        go [x] = [x]
+        go (x : xs) =
+          x : fmap (toText . (take n (repeat ' ') <>) . toString) xs
+
+loadZettelkasten ::
+  Config ->
+  Action
+    ( ZettelGraph,
+      [ZettelC],
+      Map ZettelID ZettelError
+    )
+loadZettelkasten config = do
+  formats <- getZettelFormats config
+  zettelFiles <- forM formats $ \fmt -> do
+    let pat = toString $ "*" <> zettelFormatToExtension fmt
+    files <- Rib.forEvery [pat] pure
+    pure (fmt, files)
+  loadZettelkastenFrom zettelFiles
+
+-- | Load the Zettelkasten from disk, using the given list of zettel files
+loadZettelkastenFrom ::
+  NonEmpty (ZettelFormat, [FilePath]) ->
+  Action
+    ( ZettelGraph,
+      [ZettelC],
+      Map ZettelID ZettelError
+    )
+loadZettelkastenFrom fs = do
+  notesDir <- Rib.ribInputDir
+  -- Use State monad to "gather" duplicate zettel files using same IDs, in the
+  -- `Right` of the Either state value; with the `Left` collecting the actual
+  -- zettel files to load into the graph.
+  zidMap :: Map ZettelID (Either (ZettelFormat, (FilePath, Text)) (NonEmpty FilePath)) <-
+    fmap snd $ flip runStateT Map.empty $ do
+      forM_ fs $ \(format, files) -> do
+        forM_ files $ \relPath -> do
+          case getZettelID relPath of
+            Nothing ->
+              pure ()
+            Just zid -> do
+              fmap (Map.lookup zid) get >>= \case
+                Just (Left (_f, (oldPath, _s))) -> do
+                  -- The zettel ID is already used by `oldPath`. Mark it as a dup.
+                  modify $ Map.insert zid (Right $ relPath :| [oldPath])
+                Just (Right (toList -> ambiguities)) -> do
+                  -- Third or later duplicate file with the same Zettel ID
+                  modify $ Map.insert zid (Right $ relPath :| ambiguities)
+                Nothing -> do
+                  let absPath = notesDir </> relPath
+                  lift $ need [absPath]
+                  s <- decodeUtf8With lenientDecode <$> readFileBS absPath
+                  modify $ Map.insert zid (Left (format, (relPath, s)))
+  let dups = fmap ZettelError_AmbiguousFiles $ Map.mapMaybe rightToMaybe zidMap
+      files =
+        fmap (first (id &&& readerForZettelFormat))
+          $ Map.toList
+          $ Map.fromListWith (<>)
+          $ flip fmap (Map.toList $ Map.mapMaybe leftToMaybe zidMap)
+          $ \(zid, (fmt, (path, s))) ->
+            (fmt, [(zid, path, s)])
+      (g, zs, gerrs) = G.buildZettelkasten files
+      errs = Map.unions [dups, gerrs]
+  pure (g, zs, errs)
diff --git a/src/app/Neuron/Web/Generate/Route.hs b/src/app/Neuron/Web/Generate/Route.hs
new file mode 100644
--- /dev/null
+++ b/src/app/Neuron/Web/Generate/Route.hs
@@ -0,0 +1,64 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE Rank2Types #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+-- | Static generation of routes
+module Neuron.Web.Generate.Route where
+
+import Control.Monad.Except
+import Data.Some
+import Neuron.Web.Route (Route (..), RouteConfig (..))
+import Neuron.Zettelkasten.ID
+import Reflex.Dom.Core
+import Relude
+import Rib (IsRoute (..), routeUrl, routeUrlRel)
+import qualified Text.URI as URI
+
+instance IsRoute Route where
+  routeFile = \case
+    Route_Redirect zid ->
+      routeFile $ Route_Zettel zid
+    Route_ZIndex ->
+      pure "z-index.html"
+    Route_Search ->
+      pure "search.html"
+    Route_Zettel (zettelIDText -> s) ->
+      pure $ toString s <> ".html"
+
+staticRouteConfig :: RouteConfig t m
+staticRouteConfig =
+  RouteConfig True renderStaticRoute staticRouteUrl
+  where
+    renderStaticRoute :: DomBuilder t m => Some Route -> Map Text Text -> m a -> m a
+    renderStaticRoute someR attrs w =
+      withSome someR $ \r -> do
+        let hrefAttr :: Map Text Text = "href" =: routeFor r
+        elAttr "a" (attrs <> hrefAttr) w
+    staticRouteUrl someR =
+      withSome someR $ \r -> do
+        routeFor r
+    -- Using relative URLs enables the site work in file:/// URLs
+    routeFor = routeUrlRel
+
+data BaseUrlError
+  = BaseUrlNotAbsolute
+  deriving (Eq, Show)
+
+instance Exception BaseUrlError
+
+-- | Make an absolute URI for a route, given a base URL.
+routeUri :: (HasCallStack, IsRoute r) => Text -> r a -> URI.URI
+routeUri siteBaseUrl r = either (error . toText . displayException) id $ runExcept $ do
+  baseUrl <- liftEither $ URI.mkURI siteBaseUrl
+  uri <- liftEither $ URI.mkURI $ routeUrl r
+  case URI.relativeTo uri baseUrl of
+    Nothing -> liftEither $ Left $ toException BaseUrlNotAbsolute
+    Just x -> pure x
diff --git a/src/app/Neuron/Web/Route.hs b/src/app/Neuron/Web/Route.hs
deleted file mode 100644
--- a/src/app/Neuron/Web/Route.hs
+++ /dev/null
@@ -1,105 +0,0 @@
-{-# LANGUAGE DataKinds #-}
-{-# LANGUAGE GADTs #-}
-{-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE Rank2Types #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE ScopedTypeVariables #-}
-{-# LANGUAGE ViewPatterns #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
--- | Zettel site's routes
-module Neuron.Web.Route where
-
-import Control.Monad.Except
-import qualified Data.Text as T
-import GHC.Stack
-import Neuron.Config
-import Neuron.Zettelkasten.Graph.Type
-import Neuron.Zettelkasten.ID
-import Neuron.Zettelkasten.Zettel
-import Relude
-import Rib (IsRoute (..), routeUrl, routeUrlRel)
-import Rib.Extra.OpenGraph
-import qualified Rib.Parser.MMark as MMark
-import Text.MMark.Extension (Extension)
-import qualified Text.URI as URI
-
-data Route graph a where
-  Route_Redirect :: ZettelID -> Route ZettelGraph ZettelID
-  Route_ZIndex :: Route ZettelGraph ()
-  Route_Search :: Route ZettelGraph ()
-  Route_Zettel :: ZettelID -> Route ZettelGraph (Zettel, Extension)
-
-instance IsRoute (Route graph) where
-  routeFile = \case
-    Route_Redirect zid ->
-      routeFile $ Route_Zettel zid
-    Route_ZIndex ->
-      pure "z-index.html"
-    Route_Search ->
-      pure "search.html"
-    Route_Zettel (zettelIDText -> s) ->
-      pure $ toString s <> ".html"
-
--- | Like `routeUrlRel` but takes a query parameter
-routeUrlRelWithQuery :: HasCallStack => IsRoute r => r a -> URI.RText 'URI.QueryKey -> Text -> Text
-routeUrlRelWithQuery r k v = maybe (error "Bad URI") URI.render $ do
-  param <- URI.QueryParam k <$> URI.mkQueryValue v
-  route <- URI.mkPathPiece $ routeUrlRel r
-  pure
-    URI.emptyURI
-      { URI.uriPath = Just (False, route :| []),
-        URI.uriQuery = [param]
-      }
-
-routeUri :: (HasCallStack, IsRoute r) => Text -> r a -> URI.URI
-routeUri siteBaseUrl r = either (error . toText . displayException) id $ runExcept $ do
-  baseUrl <- liftEither $ URI.mkURI siteBaseUrl
-  uri <- liftEither $ URI.mkURI $ routeUrl r
-  case URI.relativeTo uri baseUrl of
-    Nothing -> liftEither $ Left $ toException BaseUrlNotAbsolute
-    Just x -> pure x
-
--- | Return full title for a route
-routeTitle :: Config -> a -> Route graph a -> Text
-routeTitle Config {..} val =
-  withSuffix siteTitle . routeTitle' val
-  where
-    withSuffix suffix x =
-      if x == suffix
-        then x
-        else x <> " - " <> suffix
-
--- | Return the title for a route
-routeTitle' :: a -> Route graph a -> Text
-routeTitle' val = \case
-  Route_Redirect _ -> "Redirecting..."
-  Route_ZIndex -> "Zettel Index"
-  Route_Search -> "Search"
-  Route_Zettel _ ->
-    zettelTitle $ fst val
-
-routeOpenGraph :: Config -> a -> Route graph a -> OpenGraph
-routeOpenGraph Config {..} val r =
-  OpenGraph
-    { _openGraph_title = routeTitle' val r,
-      _openGraph_siteName = siteTitle,
-      _openGraph_description = case r of
-        Route_Redirect _ -> Nothing
-        Route_ZIndex -> Just "Zettelkasten Index"
-        Route_Search -> Just "Search Zettelkasten"
-        Route_Zettel _ ->
-          T.take 300 <$> MMark.getFirstParagraphText (zettelContent $ fst val),
-      _openGraph_author = author,
-      _openGraph_type = case r of
-        Route_Zettel _ -> Just $ OGType_Article (Article Nothing Nothing Nothing Nothing mempty)
-        _ -> Just OGType_Website,
-      _openGraph_image = case r of
-        Route_Zettel _ -> do
-          img <- MMark.getFirstImg (zettelContent $ fst val)
-          baseUrl <- URI.mkURI =<< siteBaseUrl
-          URI.relativeTo img baseUrl
-        _ -> Nothing,
-      _openGraph_url = Nothing
-    }
diff --git a/src/app/Neuron/Web/StructuredData.hs b/src/app/Neuron/Web/StructuredData.hs
new file mode 100644
--- /dev/null
+++ b/src/app/Neuron/Web/StructuredData.hs
@@ -0,0 +1,116 @@
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Web.StructuredData
+  ( renderStructuredData,
+  )
+where
+
+import Data.Structured.Breadcrumb (Breadcrumb)
+import qualified Data.Structured.Breadcrumb as Breadcrumb
+import qualified Data.Text as T
+import Data.Time.ISO8601 (formatISO8601)
+import Neuron.Config.Type
+import Neuron.Web.Generate.Route (routeUri)
+import Neuron.Web.Route
+import Neuron.Zettelkasten.Connection
+import Neuron.Zettelkasten.Graph (ZettelGraph)
+import qualified Neuron.Zettelkasten.Graph as G
+import Neuron.Zettelkasten.Zettel
+import Reflex.Dom.Core hiding ((&))
+import Relude
+import Rib.Extra.OpenGraph
+import qualified Rib.Parser.Pandoc as Pandoc
+import Text.Pandoc (runPure, writePlain)
+import Text.Pandoc.Definition (Block (Plain), Inline, Pandoc (..))
+import Text.Pandoc.Util (getFirstParagraphText)
+import qualified Text.URI as URI
+
+renderStructuredData :: DomBuilder t m => Config -> Route a -> (ZettelGraph, a) -> m ()
+renderStructuredData config route val = do
+  renderOpenGraph $ routeOpenGraph config (snd val) route
+  Breadcrumb.renderBreadcrumbs $ routeStructuredData config val route
+
+routeStructuredData :: Config -> (ZettelGraph, a) -> Route a -> [Breadcrumb]
+routeStructuredData Config {..} (graph, v) = \case
+  Route_Zettel _ ->
+    case siteBaseUrl of
+      Nothing -> []
+      Just baseUrl ->
+        let mkCrumb :: Zettel -> Breadcrumb.Item
+            mkCrumb Zettel {..} =
+              Breadcrumb.Item zettelTitle (Just $ routeUri baseUrl $ Route_Zettel zettelID)
+         in Breadcrumb.fromForest $ fmap mkCrumb <$> G.backlinkForest Folgezettel (sansContent v) graph
+  _ ->
+    []
+
+routeOpenGraph :: Config -> a -> Route a -> OpenGraph
+routeOpenGraph Config {..} v r =
+  OpenGraph
+    { _openGraph_title = routeTitle' v r,
+      _openGraph_siteName = siteTitle,
+      _openGraph_description = case r of
+        Route_Redirect _ -> Nothing
+        Route_ZIndex -> Just "Zettelkasten Index"
+        Route_Search -> Just "Search Zettelkasten"
+        Route_Zettel _ -> do
+          doc <- getPandocDoc v
+          para <- getFirstParagraphText doc
+          paraText <- renderPandocAsText para
+          pure $ T.take 300 paraText,
+      _openGraph_author = author,
+      _openGraph_type = case r of
+        Route_Zettel _ -> Just $ OGType_Article (Article Nothing Nothing Nothing Nothing mempty)
+        _ -> Just OGType_Website,
+      _openGraph_image = case r of
+        Route_Zettel _ -> do
+          doc <- getPandocDoc v
+          image <- URI.mkURI =<< Pandoc.getFirstImg doc
+          baseUrl <- URI.mkURI =<< siteBaseUrl
+          URI.relativeTo image baseUrl
+        _ -> Nothing,
+      _openGraph_url = do
+        baseUrl <- siteBaseUrl
+        pure $ routeUri baseUrl r
+    }
+  where
+    getPandocDoc = either (const Nothing) (Just . zettelContent)
+
+renderOpenGraph :: forall t m. DomBuilder t m => OpenGraph -> m ()
+renderOpenGraph OpenGraph {..} = do
+  meta' "author" `mapM_` _openGraph_author
+  meta' "description" `mapM_` _openGraph_description
+  requireAbsolute "OGP URL" (\ourl -> elAttr "link" ("rel" =: "canonical" <> "href" =: ourl) blank) `mapM_` _openGraph_url
+  metaOg "title" _openGraph_title
+  metaOg "site_name" _openGraph_siteName
+  whenJust _openGraph_type $ \case
+    OGType_Article (Article {..}) -> do
+      metaOg "type" "article"
+      metaOg "article:section" `mapM_` _article_section
+      metaOgTime "article:modified_time" `mapM_` _article_modifiedTime
+      metaOgTime "article:published_time" `mapM_` _article_publishedTime
+      metaOgTime "article:expiration_time" `mapM_` _article_expirationTime
+      metaOg "article:tag" `mapM_` _article_tag
+    OGType_Website -> do
+      metaOg "type" "website"
+  requireAbsolute "OGP image URL" (metaOg "image") `mapM_` _openGraph_image
+  where
+    meta' k v =
+      elAttr "meta" ("name" =: k <> "content" =: v) blank
+    metaOg k v =
+      elAttr "meta" ("property" =: ("og:" <> k) <> "content" =: v) blank
+    metaOgTime k t =
+      metaOg k $ toText $ formatISO8601 t
+    requireAbsolute :: Text -> (Text -> m ()) -> URI.URI -> m ()
+    requireAbsolute description f uri' =
+      if isJust (URI.uriScheme uri')
+        then f $ URI.render uri'
+        else error $ description <> " must be absolute. this URI is not: " <> URI.render uri'
+
+renderPandocAsText :: [Inline] -> Maybe Text
+renderPandocAsText =
+  either (const Nothing) Just . runPure . writePlain def . Pandoc mempty . pure . Plain
diff --git a/src/app/Neuron/Web/Theme.hs b/src/app/Neuron/Web/Theme.hs
deleted file mode 100644
--- a/src/app/Neuron/Web/Theme.hs
+++ /dev/null
@@ -1,79 +0,0 @@
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE ScopedTypeVariables #-}
-{-# LANGUAGE TypeApplications #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
--- | HTML & CSS
-module Neuron.Web.Theme where
-
-import Data.Text (toLower)
-import Relude
-
--- | Neuron color theme
---
--- Each theme corresponds to the color supported by Semantic UI
--- https://semantic-ui.com/usage/theming.html#sitewide-defaults
-data Theme
-  = Teal
-  | Brown
-  | Red
-  | Orange
-  | Yellow
-  | Olive
-  | Green
-  | Blue
-  | Violet
-  | Purple
-  | Pink
-  | Grey
-  | Black
-  deriving (Eq, Show, Enum, Bounded)
-
-withRgb :: Theme -> (Integer -> Integer -> Integer -> a) -> a
-withRgb theme f =
-  case theme of
-    Teal ->
-      f 0 181 173
-    Brown ->
-      f 165 103 63
-    Red ->
-      f 219 40 40
-    Orange ->
-      f 242 113 28
-    Yellow ->
-      f 251 189 8
-    Olive ->
-      f 181 204 24
-    Green ->
-      f 33 186 69
-    Blue ->
-      f 33 133 208
-    Violet ->
-      f 100 53 201
-    Purple ->
-      f 163 51 200
-    Pink ->
-      f 224 57 151
-    Grey ->
-      f 118 118 118
-    Black ->
-      f 27 28 29
-
--- | Convert Theme to Semantic UI color name
-semanticColor :: Theme -> Text
-semanticColor = toLower . show @Text
-
--- | Make Theme from Semantic UI color name
-mkTheme :: Text -> Theme
-mkTheme s =
-  fromMaybe (error $ "Unsupported theme: " <> s)
-    $ listToMaybe
-    $ catMaybes
-    $ flip fmap [minBound .. maxBound]
-    $ \theme ->
-      if s == semanticColor theme
-        then Just theme
-        else Nothing
-
-defaultTheme :: Theme
-defaultTheme = Teal
diff --git a/src/app/Neuron/Web/View.hs b/src/app/Neuron/Web/View.hs
--- a/src/app/Neuron/Web/View.hs
+++ b/src/app/Neuron/Web/View.hs
@@ -7,7 +7,6 @@
 {-# LANGUAGE Rank2Types #-}
 {-# LANGUAGE RecordWildCards #-}
 {-# LANGUAGE ScopedTypeVariables #-}
-{-# LANGUAGE TemplateHaskell #-}
 {-# LANGUAGE TupleSections #-}
 {-# LANGUAGE TypeApplications #-}
 {-# LANGUAGE ViewPatterns #-}
@@ -15,330 +14,209 @@
 
 -- | HTML & CSS
 module Neuron.Web.View
-  ( renderRouteHead,
-    renderRouteBody,
-    style,
+  ( renderRoutePage,
   )
 where
 
-import Clay hiding (id, ms, object, reverse, s, style, type_)
+import Clay ((?), Css, em, gray, important, pct, px)
 import qualified Clay as C
-import Data.Aeson ((.=), object)
+import Control.Monad.Except
+import Data.Aeson ((.=), object, toJSON)
 import qualified Data.Aeson.Text as Aeson
-import Data.Default (def)
-import Data.FileEmbed (embedStringFile)
-import Data.Foldable (maximum)
 import qualified Data.Set as Set
-import Data.Structured.Breadcrumb (Breadcrumb)
-import qualified Data.Structured.Breadcrumb as Breadcrumb
+import Data.Some
 import Data.TagTree (Tag (..))
-import Data.Tree (Tree (..))
-import Lucid
-import Neuron.Config
-import Neuron.Version (neuronVersionFull)
+import Neuron.Config.Type (Config (..))
+import Neuron.Web.Common (neuronCommonStyle, neuronFonts)
+import Neuron.Web.Manifest (Manifest, renderManifest)
+import qualified Neuron.Web.Query.View as QueryView
 import Neuron.Web.Route
+import Neuron.Web.StructuredData
+import Neuron.Web.Theme (Theme)
 import qualified Neuron.Web.Theme as Theme
-import Neuron.Zettelkasten.Connection
+import Neuron.Web.Widget
+import qualified Neuron.Web.ZIndex as ZIndex
+import qualified Neuron.Web.Zettel.CSS as ZettelCSS
+import qualified Neuron.Web.Zettel.View as ZettelView
 import qualified Neuron.Zettelkasten.Graph as G
 import Neuron.Zettelkasten.Graph (ZettelGraph)
-import Neuron.Zettelkasten.ID (ZettelID (..), zettelIDSourceFileName, zettelIDText)
-import Neuron.Zettelkasten.Query.View (renderZettelLink)
+import Neuron.Zettelkasten.ID (ZettelID (..))
 import Neuron.Zettelkasten.Zettel
-import Relude
-import qualified Rib
-import Rib.Extra.CSS (mozillaKbdStyle)
-import qualified Rib.Parser.MMark as MMark
-import Text.MMark (Extension, useExtensions)
-import Text.Pandoc.Highlighting (styleToCss, tango)
-import Text.URI.QQ
+import Reflex.Dom.Core hiding ((&))
+import Reflex.Dom.Pandoc (PandocBuilder)
+import Relude hiding ((&))
+import qualified Skylighting.Format.HTML as Skylighting
+import qualified Skylighting.Styles as Skylighting
 
-searchScript :: Text
-searchScript = $(embedStringFile "./src-js/search.js")
+-- | Render the given route
+renderRoutePage :: PandocBuilder t m => Text -> Config -> Manifest -> Route a -> (ZettelGraph, a) -> NeuronWebT t m ()
+renderRoutePage neuronVersion config manifest r val =
+  elAttr "html" ("lang" =: "en") $ do
+    el "head" $ do
+      renderRouteHead config manifest r val
+    el "body" $ do
+      renderRouteBody neuronVersion config r val
 
-renderRouteHead :: Monad m => Config -> Route graph a -> (graph, a) -> HtmlT m ()
-renderRouteHead config r val = do
-  meta_ [httpEquiv_ "Content-Type", content_ "text/html; charset=utf-8"]
-  meta_ [name_ "viewport", content_ "width=device-width, initial-scale=1"]
-  title_ $ toHtml $ routeTitle config (snd val) r
-  link_ [rel_ "shortcut icon", href_ "https://raw.githubusercontent.com/srid/neuron/master/assets/logo.ico"]
-  case r of
+renderRouteHead :: DomBuilder t m => Config -> Manifest -> Route a -> (ZettelGraph, a) -> m ()
+renderRouteHead config manifest route val = do
+  elAttr "meta" ("http-equiv" =: "Content-Type" <> "content" =: "text/html; charset=utf-8") blank
+  elAttr "meta" ("name" =: "viewport" <> "content" =: "width=device-width, initial-scale=1") blank
+  el "title" $ text $ routeTitle config (snd val) route
+  renderManifest manifest
+  case route of
     Route_Redirect _ ->
-      mempty
+      blank
     Route_Search {} -> do
-      with (script_ mempty) [src_ "https://cdn.jsdelivr.net/npm/jquery@3.5.0/dist/jquery.min.js"]
-      with (script_ mempty) [src_ "https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.js"]
-      with (script_ mempty) [src_ "https://cdn.jsdelivr.net/npm/js-search@2.0.0/dist/umd/js-search.min.js"]
+      renderCommon
+      forM_
+        [ "https://cdn.jsdelivr.net/npm/jquery@3.5.0/dist/jquery.min.js",
+          "https://cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.js",
+          "https://cdn.jsdelivr.net/npm/js-search@2.0.0/dist/umd/js-search.min.js"
+        ]
+        $ \scrpt -> do
+          elAttr "script" ("src" =: scrpt) blank
     _ -> do
-      toHtml $ routeOpenGraph config (snd val) r
-      toHtml $ routeStructuredData config val r
-      style_ [type_ "text/css"] $ styleToCss tango
+      renderCommon
+      renderStructuredData config route val
+      elAttr "style" ("type" =: "text/css") $ text $ toText $ Skylighting.styleToCss Skylighting.tango
   where
-    routeStructuredData :: Config -> (g, a) -> Route g a -> [Breadcrumb]
-    routeStructuredData Config {..} (graph, v) = \case
-      Route_Zettel _ ->
-        case siteBaseUrl of
-          Nothing -> []
-          Just baseUrl ->
-            let mkCrumb :: Zettel -> Breadcrumb.Item
-                mkCrumb Zettel {..} =
-                  Breadcrumb.Item zettelTitle (Just $ routeUri baseUrl $ Route_Zettel zettelID)
-             in Breadcrumb.fromForest $ fmap mkCrumb <$> G.backlinkForest Folgezettel (fst v) graph
-      _ ->
-        []
-
-renderRouteBody :: Monad m => Config -> Route graph a -> (graph, a) -> HtmlT m ()
-renderRouteBody config r (g, x) = do
-  case r of
-    Route_ZIndex ->
-      renderIndex config g
-    Route_Search {} ->
-      renderSearch g
-    Route_Zettel zid ->
-      renderZettel config (g, x) zid
-    Route_Redirect _ ->
-      meta_ [httpEquiv_ "Refresh", content_ $ "0; url=" <> (Rib.routeUrlRel $ Route_Zettel x)]
+    renderCommon = do
+      let neuronCss = toText $ C.renderWith C.compact [] style
+      elAttr "link" ("rel" =: "stylesheet" <> "href" =: "https://cdn.jsdelivr.net/npm/fomantic-ui@2.8.5/dist/semantic.min.css") blank
+      elAttr "style" ("type" =: "text/css") $ text neuronCss
+      elLinkGoogleFonts neuronFonts
+      when (mathJaxSupport config) $
+        elAttr "script" ("id" =: "MathJax-script" <> "src" =: "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js" <> "async" =: "") blank
+    routeTitle :: Config -> a -> Route a -> Text
+    routeTitle Config {..} v =
+      withSuffix siteTitle . routeTitle' v
+      where
+        withSuffix suffix x =
+          if x == suffix
+            then x
+            else x <> " - " <> suffix
 
-renderIndex :: Monad m => Config -> ZettelGraph -> HtmlT m ()
-renderIndex Config {..} graph = do
+renderRouteBody :: PandocBuilder t m => Text -> Config -> Route a -> (ZettelGraph, a) -> NeuronWebT t m ()
+renderRouteBody neuronVersion Config {..} r (g, x) = do
   let neuronTheme = Theme.mkTheme theme
-  h1_ [class_ "header"] $ "Zettel Index"
-  div_ [class_ "z-index"] $ do
-    -- Cycle detection.
-    case G.topSort graph of
-      Left (toList -> cyc) -> div_ [class_ "ui orange segment"] $ do
-        h2_ "Cycle detected"
-        forM_ cyc $ \zettel ->
-          li_ $ renderZettelLink def zettel
-      _ -> mempty
-    let clusters = G.categoryClusters graph
-    p_ $ do
-      "There " <> countNounBe "cluster" "clusters" (length clusters) <> " in the Zettelkasten graph. "
-      "Each cluster is rendered as a forest, with their roots (mother zettels) highlighted."
-    forM_ clusters $ \forest ->
-      div_ [class_ $ "ui stacked " <> Theme.semanticColor neuronTheme <> " segment"] $ do
-        -- Forest of zettels, beginning with mother vertices.
-        ul_ $ renderForest True Nothing True graph forest
-    renderBrandFooter True
-  where
-    countNounBe noun nounPlural = \case
-      1 -> "is 1 " <> noun
-      n -> "are " <> show n <> " " <> nounPlural
+      themeSelector = toText $ Theme.themeIdentifier neuronTheme
+      indexZettel = G.getZettel (ZettelCustomID "index") g
+  elAttr "div" ("class" =: "ui fluid container" <> "id" =: themeSelector) $ do
+    case r of
+      Route_ZIndex -> do
+        actionsNav neuronTheme indexZettel Nothing
+        divClass "ui text container" $ do
+          let zIndexData = ZIndex.buildZIndex g x
+          ZIndex.renderZIndex neuronTheme zIndexData
+          renderBrandFooter $ Just neuronVersion
+        pure mempty
+      Route_Search {} -> do
+        actionsNav neuronTheme indexZettel Nothing
+        divClass "ui text container" $ do
+          renderSearch g x
+          renderBrandFooter $ Just neuronVersion
+        pure mempty
+      Route_Zettel _ -> do
+        -- Don't inject neuron verison in zettel pages, to prevent unnecessary rebuilds when upgrading neuron
+        let noVersion = Nothing
+            zettelEditUrl = (<> toText (zettelPath $ sansContent x)) <$> editUrl
+        actionsNav neuronTheme indexZettel zettelEditUrl
+        ZettelView.renderZettel (g, x)
+          <* renderBrandFooter noVersion
+      Route_Redirect _ -> do
+        targetUrl <- neuronRouteURL $ Some $ Route_Zettel x
+        elAttr "meta" ("http-equiv" =: "Refresh" <> "content" =: ("0; url=" <> targetUrl)) blank
 
-renderSearch :: forall m. Monad m => ZettelGraph -> HtmlT m ()
-renderSearch graph = do
-  h1_ [class_ "header"] $ "Search"
-  div_ [class_ "ui fluid icon input search"] $ do
-    input_ [type_ "text", id_ "search-input"]
+renderSearch :: DomBuilder t m => ZettelGraph -> Text -> m ()
+renderSearch graph script = do
+  elClass "h1" "header" $ text "Search"
+  divClass "ui fluid icon input search" $ do
+    elAttr "input" ("type" =: "text" <> "id" =: "search-input") blank
     fa "search icon fas fa-search"
-  div_ [class_ "ui hidden divider"] mempty
+  divClass "ui hidden divider" blank
   let allZettels = G.getZettels graph
       allTags = Set.fromList $ concatMap zettelTags allZettels
       index = object ["zettels" .= fmap (object . zettelJson) allZettels, "tags" .= allTags]
-  div_ [class_ "ui fluid multiple search selection dropdown", id_ "search-tags"] $ do
-    with (input_ mempty) [name_ "tags", type_ "hidden"]
-    with (i_ mempty) [class_ "dropdown icon"]
-    div_ [class_ "default text"] "Select tags…"
-    div_ [class_ "menu"] $ do
-      forM_ allTags $ \tag -> do
-        div_ [class_ "item"] $ toHtml (unTag tag)
-  div_ [class_ "ui divider"] mempty
-  ul_ [id_ "search-results", class_ "zettel-list"] mempty
-  script_ $ "let index = " <> toText (Aeson.encodeToLazyText index) <> ";"
-  script_ searchScript
-
-renderZettel :: forall m. Monad m => Config -> (ZettelGraph, (Zettel, Extension)) -> ZettelID -> HtmlT m ()
-renderZettel config@Config {..} (graph, (z@Zettel {..}, ext)) zid = do
-  let neuronTheme = Theme.mkTheme theme
-  div_ [class_ "zettel-view"] $ do
-    div_ [class_ "ui raised segments"] $ do
-      div_ [class_ "ui top attached segment"] $ do
-        h1_ [class_ "header"] $ toHtml zettelTitle
-        let mmarkExts = getMarkdownExtensions config
-        MMark.render $ useExtensions (ext : mmarkExts) zettelContent
-        whenNotNull zettelTags $ \_ ->
-          renderTags zettelTags
-        forM_ zettelDay $ \day ->
-          div_ [class_ "date", title_ "Zettel creation date"] $ toHtml $ show @Text day
-    div_ [class_ $ "ui inverted " <> Theme.semanticColor neuronTheme <> " top attached connections segment"] $ do
-      div_ [class_ "ui two column grid"] $ do
-        div_ [class_ "column"] $ do
-          div_ [class_ "ui header"] "Down"
-          ul_ $ renderForest True (Just 2) False graph $
-            G.frontlinkForest Folgezettel z graph
-        div_ [class_ "column"] $ do
-          div_ [class_ "ui header"] "Up"
-          ul_ $ do
-            renderForest True Nothing False graph $
-              G.backlinkForest Folgezettel z graph
-          div_ [class_ "ui header"] "Other backlinks"
-          ul_ $ do
-            renderForest True Nothing False graph
-              $ fmap (flip Node [])
-              $ G.backlinks OrdinaryConnection z graph
-    div_ [class_ "ui inverted black bottom attached footer segment"] $ do
-      div_ [class_ "ui equal width grid"] $ do
-        div_ [class_ "center aligned column"] $ do
-          let homeUrl = maybe "." (const "index.html") $ G.getZettel (ZettelCustomID "index") graph
-          a_ [href_ homeUrl, title_ "/"] $ fa "fas fa-home"
-        whenJust editUrl $ \urlPrefix ->
-          div_ [class_ "center aligned column"] $ do
-            a_ [href_ $ urlPrefix <> toText (zettelIDSourceFileName zid), title_ "Edit this Zettel"] $ fa "fas fa-edit"
-        div_ [class_ "center aligned column"] $ do
-          a_ [href_ (Rib.routeUrlRel Route_Search), title_ "Search Zettels"] $ fa "fas fa-search"
-        div_ [class_ "center aligned column"] $ do
-          a_ [href_ (Rib.routeUrlRel Route_ZIndex), title_ "All Zettels (z-index)"] $
-            fa "fas fa-tree"
-    renderBrandFooter False
-
-renderBrandFooter :: Monad m => Bool -> HtmlT m ()
-renderBrandFooter withVersion =
-  div_ [class_ "ui one column grid footer-version"] $ do
-    div_ [class_ "center aligned column"] $ do
-      p_ $ do
-        "Generated by "
-        a_ [href_ "https://github.com/srid/neuron"] "Neuron"
-        when withVersion $ do
-          " "
-          code_ $ toHtml @Text neuronVersionFull
+  elAttr "div" ("class" =: "ui fluid multiple search selection dropdown" <> "id" =: "search-tags") $ do
+    elAttr "input" ("name" =: "tags" <> "type" =: "hidden") blank
+    elClass "i" "dropdown icon" blank
+    divClass "default text" $ text "Select tags…"
+    divClass "menu" $ do
+      forM_ allTags $ \t -> do
+        divClass "item" $ text (unTag t)
+  divClass "ui divider" blank
+  elAttr "ul" ("id" =: "search-results" <> "class" =: "zettel-list") blank
+  el "script" $ text $ "let index = " <> toText (Aeson.encodeToLazyText index) <> ";"
+  el "script" $ text script
+  where
+    zettelJson Zettel {..} =
+      [ "id" .= toJSON zettelID,
+        "title" .= zettelTitle,
+        "tags" .= zettelTags,
+        "day" .= zettelDay
+      ]
 
-renderTags :: Monad m => [Tag] -> HtmlT m ()
-renderTags tags = do
-  forM_ tags $ \(unTag -> tag) -> do
-    -- TODO: Ideally this should be at the top, not bottom. But putting it at
-    -- the top pushes the zettel content down, introducing unnecessary white
-    -- space below the title. So we put it at the bottom for now.
-    span_ [class_ "ui black right ribbon label", title_ "Tag"] $ do
-      a_
-        [ href_ $ routeUrlRelWithQuery Route_Search [queryKey|tag|] tag,
-          title_ ("See all zettels tagged '" <> tag <> "'")
-        ]
-        $ toHtml tag
-    p_ mempty
+renderBrandFooter :: DomBuilder t m => Maybe Text -> m ()
+renderBrandFooter mver =
+  divClass "ui one column grid footer-version" $ do
+    divClass "center aligned column" $ do
+      divClass "ui tiny image" $ do
+        elAttr "a" ("href" =: "https://neuron.zettel.page") $ do
+          elAttr
+            "img"
+            ( "src" =: "https://raw.githubusercontent.com/srid/neuron/master/assets/neuron.svg"
+                <> "alt" =: "logo"
+                <> "title" =: ("Generated by Neuron" <> maybe "" (" " <>) mver)
+            )
+            blank
 
 -- | Font awesome element
-fa :: Monad m => Text -> HtmlT m ()
-fa k = with i_ [class_ k] mempty
+fa :: DomBuilder t m => Text -> m ()
+fa k = elClass "i" k blank
 
-renderForest ::
-  Monad m =>
-  Bool ->
-  Maybe Int ->
-  Bool ->
-  ZettelGraph ->
-  [Tree Zettel] ->
-  HtmlT m ()
-renderForest isRoot maxLevel renderingFullTree g trees =
-  case maxLevel of
-    Just 0 -> mempty
-    _ -> do
-      forM_ (sortForest trees) $ \(Node zettel subtrees) ->
-        li_ $ do
-          let zettelDiv =
-                div_
-                  [class_ $ bool "" "ui black label" renderingFullTree]
-          bool id zettelDiv isRoot $
-            renderZettelLink def zettel
-          when renderingFullTree $ do
-            " "
-            case G.backlinks Folgezettel zettel g of
-              conns@(_ : _ : _) ->
-                -- Has two or more category backlinks
-                forM_ conns $ \zettel2 -> do
-                  i_ [class_ "fas fa-link", title_ $ zettelIDText (zettelID zettel2) <> " " <> zettelTitle zettel2] mempty
-              _ -> mempty
-          when (length subtrees > 0) $ do
-            ul_ $ renderForest False ((\n -> n - 1) <$> maxLevel) renderingFullTree g subtrees
-  where
-    -- Sort trees so that trees containing the most recent zettel (by ID) come first.
-    sortForest = reverse . sortOn maximum
+actionsNav :: DomBuilder t m => Theme -> Maybe Zettel -> Maybe Text -> NeuronWebT t m ()
+actionsNav theme mIndexZettel mEditUrl = elClass "nav" "top-menu" $ do
+  divClass ("ui inverted compact neuron icon menu " <> Theme.semanticColor theme) $ do
+    forM_ mIndexZettel $ \Zettel {..} ->
+      neuronRouteLink (Some $ Route_Zettel zettelID) ("class" =: "left item" <> "title" =: "Home") $
+        semanticIcon "home"
+    neuronRouteLink (Some Route_Search) ("class" =: "left item" <> "title" =: "Search Zettels") $ do
+      semanticIcon "search"
+    forM_ mEditUrl $ \editUrl -> do
+      let attrs = ("href" =: editUrl <> "title" =: "Edit this Zettel")
+      elAttr "a" ("class" =: "center item" <> attrs) $ do
+        semanticIcon "edit"
+    neuronRouteLink (Some Route_ZIndex) ("class" =: "right item" <> "title" =: "All Zettels (z-index)") $
+      semanticIcon "tree"
 
-style :: Config -> Css
-style Config {..} = do
-  let neuronTheme = Theme.mkTheme theme
-      linkColor = Theme.withRgb neuronTheme C.rgb
-  "span.zettel-link-container span.zettel-link a" ? do
-    C.fontWeight C.bold
-    C.color linkColor
-    C.textDecoration C.none
-  "span.zettel-link-container span.zettel-link a:hover" ? do
-    C.backgroundColor linkColor
-    C.color C.white
-  "span.zettel-link-container span.extra" ? do
-    C.color C.auto
-    C.paddingRight $ em 0.3
-  "div.z-index" ? do
-    C.ul ? do
-      C.listStyleType C.square
-      C.paddingLeft $ em 1.5
-  "div.zettel-view" ? do
-    "div.date" ? do
-      C.textAlign C.center
-      C.color C.gray
-    C.ul ? do
-      C.paddingLeft $ em 1.5
-      C.listStyleType C.square
-      C.li ? do
-        mempty -- C.paddingBottom $ em 1
-    C.h1 ? do
-      C.paddingTop $ em 0.2
-      C.paddingBottom $ em 0.2
-      C.textAlign C.center
-      C.fontWeight $ weight 700
-      C.backgroundColor $ Theme.withRgb neuronTheme C.rgba 0.1
-    C.h2 ? do
-      C.fontWeight $ weight 600
-      C.borderBottom C.solid (px 1) C.steelblue
-      C.marginBottom $ em 0.5
-    C.h3 ? do
-      C.fontWeight $ weight 400
-      C.margin (px 0) (px 0) (em 0.4) (px 0)
-    C.h4 ? do
-      C.fontWeight $ weight 300
-      C.opacity 0.8
-    codeStyle
-    blockquoteStyle
-    kbd ? mozillaKbdStyle
-  "div.tag-tree" ? do
-    "div.node" ? do
-      C.fontWeight C.bold
-      "a.inactive" ? do
-        C.color "#555"
-  "div.connections" ? do
-    "a" ? do
-      C.important $ color white
-    "a:hover" ? do
-      C.opacity 0.5
-  ".footer" ? do
-    "a" ? do
-      C.color white
-  ".footer-version, .footer-version a, .footer-version a:visited" ? do
-    C.color gray
-  ".footer-version a" ? do
-    C.fontWeight C.bold
-  ".footer-version" ? do
-    C.fontSize $ em 0.7
-  "[data-tooltip]:after" ? do
-    C.fontSize $ em 0.7
+style :: Css
+style = do
+  "body" ? do
+    neuronCommonStyle
+    ZIndex.style
+    ZettelCSS.zettelCss
+    QueryView.style
+    footerStyle
+    navBarStyle
   where
-    codeStyle = do
-      C.code ? do
-        sym margin auto
-        fontSize $ pct 90
-      "code, pre, tt" ? do
-        fontFamily ["SFMono-Regular", "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New"] [monospace]
-      pre ? do
-        sym padding $ em 0.5
-        C.overflow auto
-        C.maxWidth $ pct 100
-      "div.source-code" ? do
-        marginLeft auto
-        marginRight auto
-        pre ? do
-          backgroundColor "#f8f8f8"
-    -- https://css-tricks.com/snippets/css/simple-and-nice-blockquote-styling/
-    blockquoteStyle =
-      C.blockquote ? do
-        C.backgroundColor "#f9f9f9"
-        C.borderLeft C.solid (px 10) "#ccc"
-        sym2 C.margin (em 1.5) (px 0)
-        sym2 C.padding (em 0.5) (px 10)
+    footerStyle = do
+      ".footer-version img" ? do
+        C.filter $ C.grayscale $ pct 100
+      ".footer-version img:hover" ? do
+        C.filter $ C.grayscale $ pct 0
+      ".footer-version, .footer-version a, .footer-version a:visited" ? do
+        C.color gray
+      ".footer-version a" ? do
+        C.fontWeight C.bold
+      ".footer-version" ? do
+        important $ C.marginTop $ em 1
+        C.fontSize $ em 0.7
+    navBarStyle = do
+      "nav.top-menu" ? do
+        C.paddingTop $ em 1
+        C.paddingBottom $ em 1
+        C.justifyContent C.center
+        C.textAlign C.center
+        "> *" ? do
+          C.paddingLeft $ px 0
+          C.paddingRight $ px 0
diff --git a/src/app/Neuron/Zettelkasten/Connection.hs b/src/app/Neuron/Zettelkasten/Connection.hs
deleted file mode 100644
--- a/src/app/Neuron/Zettelkasten/Connection.hs
+++ /dev/null
@@ -1,23 +0,0 @@
-{-# LANGUAGE DeriveAnyClass #-}
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
-module Neuron.Zettelkasten.Connection where
-
-import Data.Aeson (ToJSON)
-import Relude
-
--- | Represent the connection between zettels
-data Connection
-  = -- | A folgezettel points to a zettel that is conceptually a *part* of the
-    -- parent zettel.
-    Folgezettel
-  | -- | Any other ordinary connection (eg: "See also")
-    OrdinaryConnection
-  deriving (Eq, Ord, Show, Enum, Bounded, Generic, ToJSON)
-
-instance Semigroup Connection where
-  -- A folgezettel link trumps all other kinds in that zettel.
-  Folgezettel <> _ = Folgezettel
-  _ <> Folgezettel = Folgezettel
-  OrdinaryConnection <> OrdinaryConnection = OrdinaryConnection
diff --git a/src/app/Neuron/Zettelkasten/Error.hs b/src/app/Neuron/Zettelkasten/Error.hs
deleted file mode 100644
--- a/src/app/Neuron/Zettelkasten/Error.hs
+++ /dev/null
@@ -1,40 +0,0 @@
-{-# LANGUAGE FlexibleContexts #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE ScopedTypeVariables #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
-module Neuron.Zettelkasten.Error
-  ( NeuronError (..),
-  )
-where
-
-import Neuron.Zettelkasten.ID (ZettelID, zettelIDSourceFileName, zettelIDText)
-import Neuron.Zettelkasten.Query.Error
-import Relude
-import qualified Text.Show
-import qualified Text.URI as URI
-
-data NeuronError
-  = -- A zettel file contains invalid link that neuron cannot parse
-    NeuronError_BadQuery ZettelID QueryError
-  deriving (Eq)
-
-instance Show NeuronError where
-  show (NeuronError_BadQuery fromZid e) =
-    let msg = case e of
-          Left qe ->
-            "it contains a query URI (" <> URI.render (queryParseErrorUri qe) <> ") " <> case qe of
-              QueryParseError_UnsupportedHost _uri ->
-                "with unsupported host"
-              QueryParseError_InvalidID _uri e'' ->
-                "with invalidID: " <> show e''
-          Right (QueryResultError_NoSuchZettel zid) ->
-            "Zettel "
-              <> zettelIDText zid
-              <> " does not exist"
-     in toString $
-          unlines
-            [ "",
-              "  Zettel file \"" <> toText (zettelIDSourceFileName fromZid) <> "\" is malformed:",
-              "    " <> msg
-            ]
diff --git a/src/app/Neuron/Zettelkasten/Graph.hs b/src/app/Neuron/Zettelkasten/Graph.hs
deleted file mode 100644
--- a/src/app/Neuron/Zettelkasten/Graph.hs
+++ /dev/null
@@ -1,131 +0,0 @@
-{-# LANGUAGE FlexibleContexts #-}
-{-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE ScopedTypeVariables #-}
-{-# LANGUAGE TupleSections #-}
-{-# LANGUAGE TypeFamilies #-}
-{-# LANGUAGE ViewPatterns #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
-module Neuron.Zettelkasten.Graph
-  ( -- * Graph type
-    ZettelGraph,
-
-    -- * Construction
-    loadZettels,
-    loadZettelkasten,
-    loadZettelkastenFrom,
-
-    -- * Graph functions
-    getZettels,
-    getZettel,
-    topSort,
-    frontlinkForest,
-    backlinkForest,
-    backlinks,
-    clusters,
-    categoryClusters,
-  )
-where
-
-import Control.Monad.Except
-import Data.Foldable (maximum)
-import qualified Data.Graph.Labelled as G
-import qualified Data.Map.Strict as Map
-import Data.Traversable (for)
-import Data.Tree
-import Development.Shake
-import Neuron.Zettelkasten.Connection
-import Neuron.Zettelkasten.Error
-import Neuron.Zettelkasten.Graph.Type
-import Neuron.Zettelkasten.ID
-import Neuron.Zettelkasten.Query.Eval
-import Neuron.Zettelkasten.Zettel
-import Relude
-import qualified Rib
-import System.FilePath
-import Text.MMark.Extension (Extension)
-import Text.MMark.Extension.ReplaceLink (replaceLink)
-
-loadZettels :: Action [Zettel]
-loadZettels =
-  fmap (fmap fst . snd) loadZettelkasten
-
-loadZettelkasten :: Action (ZettelGraph, [(Zettel, Extension)])
-loadZettelkasten =
-  loadZettelkastenFrom =<< Rib.forEvery ["*.md"] pure
-
--- | Load the Zettelkasten from disk, using the given list of zettel files
-loadZettelkastenFrom :: [FilePath] -> Action (ZettelGraph, [(Zettel, Extension)])
-loadZettelkastenFrom files = do
-  notesDir <- Rib.ribInputDir
-  zettels <- forM files $ \((notesDir </>) -> path) -> do
-    s <- fmap toText $ readFile' path
-    let zid = mkZettelID path
-    case mkZettelFromMarkdown zid s snd of
-      Left e -> fail $ toString e
-      Right zettel -> pure zettel
-  -- zettels <- mkZettelFromPath `mapM` files
-  either (fail . show) pure $ mkZettelGraph zettels
-
--- | Build the Zettelkasten graph from a list of zettels
---
--- Also return the markdown extension to use for each zettel.
-mkZettelGraph ::
-  forall m.
-  MonadError NeuronError m =>
-  [Zettel] ->
-  m (ZettelGraph, [(Zettel, Extension)])
-mkZettelGraph zettels = do
-  zettelsWithQueryResults <-
-    liftEither $ runExcept $ do
-      for zettels $ \z ->
-        withExcept (NeuronError_BadQuery (zettelID z)) $
-          (z,) <$> evalZettelLinks zettels z
-  zettelsWithExtensions <- for zettelsWithQueryResults $ \(z, resMap) -> liftEither $ runExcept $ do
-    pure $ (z,) $ replaceLink $ snd `Map.map` resMap
-  let edges :: [(Maybe Connection, Zettel, Zettel)] = flip concatMap zettelsWithQueryResults $ \(z, resMap) ->
-        let conns :: [(Connection, Zettel)] = concatMap fst $ Map.elems resMap
-         in conns <&> \(cs, z2) -> (Just cs, z, z2)
-  pure (G.mkGraphFrom zettels edges, zettelsWithExtensions)
-
-frontlinkForest :: Connection -> Zettel -> ZettelGraph -> Forest Zettel
-frontlinkForest conn z =
-  G.obviateRootUnlessForest z
-    . G.dfsForestFrom [z]
-    . G.induceOnEdge (== Just conn)
-
-backlinkForest :: Connection -> Zettel -> ZettelGraph -> Forest Zettel
-backlinkForest conn z =
-  G.obviateRootUnlessForest z
-    . G.dfsForestBackwards z
-    . G.induceOnEdge (== Just conn)
-
-backlinks :: Connection -> Zettel -> ZettelGraph -> [Zettel]
-backlinks conn z =
-  G.preSet z . G.induceOnEdge (== Just conn)
-
-categoryClusters :: ZettelGraph -> [Forest Zettel]
-categoryClusters (categoryGraph -> g) =
-  let cs :: [[Zettel]] = sortMothers $ clusters g
-   in flip fmap cs $ \zs -> G.dfsForestFrom zs g
-  where
-    -- Sort clusters with newer mother zettels appearing first.
-    sortMothers :: [NonEmpty Zettel] -> [[Zettel]]
-    sortMothers = sortOn (Down . maximum) . fmap (sortOn Down . toList)
-
-clusters :: ZettelGraph -> [NonEmpty Zettel]
-clusters = G.clusters . categoryGraph
-
-topSort :: ZettelGraph -> Either (NonEmpty Zettel) [Zettel]
-topSort = G.topSort . categoryGraph
-
-categoryGraph :: ZettelGraph -> ZettelGraph
-categoryGraph = G.induceOnEdge (== Just Folgezettel)
-
-getZettels :: ZettelGraph -> [Zettel]
-getZettels = G.getVertices
-
-getZettel :: ZettelID -> ZettelGraph -> Maybe Zettel
-getZettel = G.findVertex
diff --git a/src/app/Neuron/Zettelkasten/Graph/Type.hs b/src/app/Neuron/Zettelkasten/Graph/Type.hs
deleted file mode 100644
--- a/src/app/Neuron/Zettelkasten/Graph/Type.hs
+++ /dev/null
@@ -1,23 +0,0 @@
-{-# LANGUAGE FlexibleContexts #-}
-{-# LANGUAGE ScopedTypeVariables #-}
-{-# LANGUAGE TypeFamilies #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
-module Neuron.Zettelkasten.Graph.Type
-  ( -- * Graph type
-    ZettelGraph,
-  )
-where
-
-import Data.Graph.Labelled
-import Neuron.Zettelkasten.Connection
-import Neuron.Zettelkasten.Zettel
-import Relude
-
--- | The Zettelkasten graph
---
--- Edges are labelled with `Connection`; Maybe is used to provide the
--- `Algebra.Graph.Label.zero` value for the edge label, which is `Nothing` in
--- our case, and is effectively the same as there not being an edge between
--- those vertices.
-type ZettelGraph = LabelledGraph Zettel (Maybe Connection)
diff --git a/src/app/Neuron/Zettelkasten/Query.hs b/src/app/Neuron/Zettelkasten/Query.hs
deleted file mode 100644
--- a/src/app/Neuron/Zettelkasten/Query.hs
+++ /dev/null
@@ -1,129 +0,0 @@
-{-# LANGUAGE FlexibleContexts #-}
-{-# LANGUAGE FlexibleInstances #-}
-{-# LANGUAGE GADTs #-}
-{-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE ScopedTypeVariables #-}
-{-# LANGUAGE StandaloneDeriving #-}
-{-# LANGUAGE TemplateHaskell #-}
-{-# LANGUAGE TupleSections #-}
-{-# LANGUAGE ViewPatterns #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
--- | Queries to the Zettel store
-module Neuron.Zettelkasten.Query where
-
-import Control.Monad.Except
-import Data.Aeson
-import Data.Aeson.GADT.TH
-import Data.GADT.Compare.TH
-import Data.GADT.Show.TH
-import qualified Data.Map.Strict as Map
-import Data.Some
-import Data.TagTree (Tag, TagPattern (..), tagMatch, tagMatchAny, tagTree)
-import Data.Tree (Tree (..))
-import Lucid
-import Neuron.Zettelkasten.Connection
-import Neuron.Zettelkasten.ID
-import Neuron.Zettelkasten.Query.Theme
-import Neuron.Zettelkasten.Zettel
-import Relude
-import System.FilePath
-
--- | Query represents a way to query the Zettelkasten.
---
--- TODO: Support querying connections, a la:
---   LinksTo ZettelID
---   LinksFrom ZettelID
-data Query r where
-  Query_ZettelByID :: ZettelID -> Maybe Connection -> Query (Maybe Zettel)
-  Query_ZettelsByTag :: [TagPattern] -> Maybe Connection -> ZettelsView -> Query [Zettel]
-  Query_Tags :: [TagPattern] -> Query (Map Tag Natural)
-
-instance ToHtml (Some Query) where
-  toHtmlRaw = toHtml
-  toHtml q =
-    div_ [class_ "ui horizontal divider", title_ "Neuron Query"] $ do
-      case q of
-        Some (Query_ZettelByID _ _) ->
-          mempty
-        Some (Query_ZettelsByTag [] _mconn _mview) ->
-          "All zettels"
-        Some (Query_ZettelsByTag (fmap unTagPattern -> pats) _mconn _mview) -> do
-          let qs = intercalate ", " pats
-              desc = toText $ "Zettels tagged '" <> qs <> "'"
-           in span_ [class_ "ui basic pointing below black label", title_ desc] $ do
-                i_ [class_ "tags icon"] mempty
-                toHtml qs
-        Some (Query_Tags []) ->
-          "All tags"
-        Some (Query_Tags (fmap unTagPattern -> pats)) -> do
-          let qs = intercalate ", " pats
-          toHtml $ "Tags matching '" <> qs <> "'"
-
--- | Run the given query and return the results.
-runQuery :: [Zettel] -> Query r -> r
-runQuery zs = \case
-  Query_ZettelByID zid _ ->
-    find ((== zid) . zettelID) zs
-  Query_ZettelsByTag pats _mconn _mview ->
-    sortZettelsReverseChronological $ flip filter zs $ \Zettel {..} ->
-      and $ flip fmap pats $ \pat ->
-        any (tagMatch pat) zettelTags
-  Query_Tags [] ->
-    allTags
-  Query_Tags pats ->
-    Map.filterWithKey (const . tagMatchAny pats) allTags
-  where
-    allTags :: Map.Map Tag Natural
-    allTags =
-      Map.fromListWith (+) $
-        concatMap (\Zettel {..} -> (,1) <$> zettelTags) zs
-
-queryResultJson :: forall r. (ToJSON (Query r)) => FilePath -> Query r -> r -> Value
-queryResultJson notesDir q r =
-  toJSON $
-    object
-      [ "query" .= toJSON q,
-        "result" .= resultJson
-      ]
-  where
-    resultJson :: Value
-    resultJson = case q of
-      Query_ZettelByID _ _mconn ->
-        toJSON $ zettelJsonFull <$> r
-      Query_ZettelsByTag _ _mconn _mview ->
-        toJSON $ zettelJsonFull <$> r
-      Query_Tags _ ->
-        toJSON $ treeToJson <$> tagTree r
-    zettelJsonFull :: ZettelT c -> Value
-    zettelJsonFull z@Zettel {..} =
-      object $
-        [ "path" .= (notesDir </> zettelIDSourceFileName zettelID)
-        ]
-          <> zettelJson z
-    treeToJson (Node (tag, count) children) =
-      object
-        [ "name" .= tag,
-          "count" .= count,
-          "children" .= fmap treeToJson children
-        ]
-
-deriveJSONGADT ''Query
-
-deriveGEq ''Query
-
-deriveGShow ''Query
-
-deriving instance Show (Query (Maybe Zettel))
-
-deriving instance Show (Query [Zettel])
-
-deriving instance Show (Query (Map Tag Natural))
-
-deriving instance Eq (Query (Maybe Zettel))
-
-deriving instance Eq (Query [Zettel])
-
-deriving instance Eq (Query (Map Tag Natural))
diff --git a/src/app/Neuron/Zettelkasten/Query/Error.hs b/src/app/Neuron/Zettelkasten/Query/Error.hs
deleted file mode 100644
--- a/src/app/Neuron/Zettelkasten/Query/Error.hs
+++ /dev/null
@@ -1,24 +0,0 @@
-{-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
-module Neuron.Zettelkasten.Query.Error where
-
-import Neuron.Zettelkasten.ID (InvalidID, ZettelID)
-import Relude
-import Text.URI
-
-type QueryError = Either QueryParseError QueryResultError
-
-data QueryParseError
-  = QueryParseError_InvalidID URI InvalidID
-  | QueryParseError_UnsupportedHost URI
-  deriving (Eq, Show)
-
--- | This error is only thrown when *using* (eg: in HTML) the query results.
-data QueryResultError = QueryResultError_NoSuchZettel ZettelID
-  deriving (Eq, Show)
-
-queryParseErrorUri :: QueryParseError -> URI
-queryParseErrorUri = \case
-  QueryParseError_InvalidID uri _ -> uri
-  QueryParseError_UnsupportedHost uri -> uri
diff --git a/src/app/Neuron/Zettelkasten/Query/Eval.hs b/src/app/Neuron/Zettelkasten/Query/Eval.hs
deleted file mode 100644
--- a/src/app/Neuron/Zettelkasten/Query/Eval.hs
+++ /dev/null
@@ -1,69 +0,0 @@
-{-# LANGUAGE FlexibleContexts #-}
-{-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE TupleSections #-}
-{-# LANGUAGE TypeFamilies #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
-module Neuron.Zettelkasten.Query.Eval
-  ( evalZettelLinks,
-  )
-where
-
-import Control.Monad.Except
-import Data.Dependent.Sum
-import qualified Data.Map.Strict as Map
-import Data.Some
-import Data.Traversable (for)
-import Lucid
-import Neuron.Zettelkasten.Connection
-import Neuron.Zettelkasten.Query
-import Neuron.Zettelkasten.Query.Error
-import Neuron.Zettelkasten.Query.Parser (queryFromMarkdownLink)
-import Neuron.Zettelkasten.Query.View (renderQueryLink)
-import Neuron.Zettelkasten.Zettel
-import Relude
-import Text.MMark.MarkdownLink
-
-type EvalResult = ([(Connection, Zettel)], Html ())
-
--- | Evaluate all queries in a zettel
---
--- Return the queries with results as a map from the original markdown link.
-evalZettelLinks ::
-  MonadError QueryError m =>
-  [Zettel] ->
-  Zettel ->
-  m (Map MarkdownLink EvalResult)
-evalZettelLinks zettels Zettel {..} =
-  fmap (Map.fromList . catMaybes) $ for (extractLinks zettelContent) $ \ml ->
-    fmap (ml,) <$> evalMarkdownLink zettels ml
-
--- | Fully evaluate the query in a markdown link.
-evalMarkdownLink ::
-  MonadError QueryError m =>
-  [Zettel] ->
-  MarkdownLink ->
-  m (Maybe EvalResult)
-evalMarkdownLink zettels ml =
-  liftEither $ runExcept $ do
-    mq <- withExcept Left $ queryFromMarkdownLink ml
-    case mq of
-      Nothing -> pure Nothing
-      Just someQ -> Just <$> do
-        let qres = withSome someQ $ \q ->
-              q :=> Identity (runQuery zettels q)
-            conns = getConnections qres
-        view <-
-          withExcept Right $
-            renderQueryLink qres
-        pure (conns, view)
-  where
-    getConnections :: DSum Query Identity -> [(Connection, Zettel)]
-    getConnections = \case
-      Query_ZettelByID _ mconn :=> Identity mres ->
-        maybe [] pure $ (fromMaybe Folgezettel mconn,) <$> mres
-      Query_ZettelsByTag _ mconn _mview :=> Identity res ->
-        (fromMaybe Folgezettel mconn,) <$> res
-      _ ->
-        []
diff --git a/src/app/Neuron/Zettelkasten/Query/Parser.hs b/src/app/Neuron/Zettelkasten/Query/Parser.hs
deleted file mode 100644
--- a/src/app/Neuron/Zettelkasten/Query/Parser.hs
+++ /dev/null
@@ -1,107 +0,0 @@
-{-# LANGUAGE DeriveAnyClass #-}
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE FlexibleContexts #-}
-{-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE TemplateHaskell #-}
-{-# LANGUAGE TypeFamilies #-}
-{-# LANGUAGE ViewPatterns #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
-module Neuron.Zettelkasten.Query.Parser where
-
-import Control.Monad.Except
-import Data.Some
-import Data.TagTree (mkTagPattern)
-import Neuron.Zettelkasten.Connection
-import Neuron.Zettelkasten.ID
-import Neuron.Zettelkasten.Query
-import Neuron.Zettelkasten.Query.Error
-import Neuron.Zettelkasten.Query.Theme
-import Relude
-import Text.MMark.MarkdownLink (MarkdownLink (..))
-import qualified Text.URI as URI
-import Text.URI.QQ (queryKey)
-import Text.URI.Util (getQueryParam, hasQueryFlag)
-
--- | Parse a query from the given URI.
---
--- This function is used only in the CLI. For handling links in a Markdown file,
--- your want `queryFromMarkdownLink` which allows specifying the link text as
--- well.
-queryFromURI :: MonadError QueryParseError m => URI.URI -> m (Maybe (Some Query))
-queryFromURI uri = do
-  -- We are setting markdownLinkText to the URI to support the new short links
-  queryFromMarkdownLink $ MarkdownLink {markdownLinkUri = uri, markdownLinkText = URI.render uri}
-
-queryFromMarkdownLink :: MonadError QueryParseError m => MarkdownLink -> m (Maybe (Some Query))
-queryFromMarkdownLink MarkdownLink {markdownLinkUri = uri, markdownLinkText = linkText} =
-  case fmap URI.unRText (URI.uriScheme uri) of
-    Just proto | not angleBracketLink && proto `elem` ["z", "zcf"] -> do
-      zid <- liftEither $ first (QueryParseError_InvalidID uri) $ parseZettelID' linkText
-      let mconn = if proto == "zcf" then Just OrdinaryConnection else Nothing
-      pure $ Just $ Some $ Query_ZettelByID zid mconn
-    Just proto | proto `elem` ["zquery", "zcfquery"] ->
-      case uriHost uri of
-        Right "search" -> do
-          let mconn = if proto == "zcfquery" then Just OrdinaryConnection else Nothing
-          pure $ Just $ Some $
-            Query_ZettelsByTag (tagPatterns "tag") mconn queryView
-        Right "tags" ->
-          pure $ Just $ Some $ Query_Tags (tagPatterns "filter")
-        _ ->
-          throwError $ QueryParseError_UnsupportedHost uri
-    _ -> pure $ do
-      -- Initial support for the upcoming short links.
-      -- First, we expect that this is inside <..> (so same link text as link)
-      guard angleBracketLink
-      -- Then, non-relevant parts of the URI should be empty
-      guard
-        `mapM_` [ URI.uriAuthority uri == Left False,
-                  URI.uriFragment uri == Nothing
-                ]
-      let mconn =
-            if hasQueryFlag [queryKey|cf|] uri
-              then Just OrdinaryConnection
-              else Nothing
-      case fmap URI.unRText (URI.uriScheme uri) of
-        Just "z" -> do
-          fmap snd (URI.uriPath uri) >>= \case
-            (URI.unRText -> "zettels") :| [] -> do
-              pure $ Some $ Query_ZettelsByTag (tagPatterns "tag") mconn queryView
-            (URI.unRText -> "tags") :| [] -> do
-              pure $ Some $ Query_Tags (tagPatterns "filter")
-            _ ->
-              Nothing
-        Just _ -> do
-          Nothing
-        Nothing -> do
-          -- Alias to short links
-          fmap snd (URI.uriPath uri) >>= \case
-            (URI.unRText -> path) :| [] -> do
-              zid <- rightToMaybe $ parseZettelID' path
-              pure $ Some $ Query_ZettelByID zid mconn
-            _ ->
-              -- Multiple path elements, not supported
-              Nothing
-  where
-    angleBracketLink = URI.render uri == linkText
-    tagPatterns k =
-      mkTagPattern <$> getParamValues k uri
-    queryView =
-      let isTimeline =
-            -- linkTheme=withDate is legacy format; timeline is current standard.
-            getQueryParam [queryKey|linkTheme|] uri == Just "withDate"
-              || hasQueryFlag [queryKey|timeline|] uri
-          isGrouped = hasQueryFlag [queryKey|grouped|] uri
-       in ZettelsView (LinkView isTimeline) isGrouped
-    getParamValues k u =
-      flip mapMaybe (URI.uriQuery u) $ \case
-        URI.QueryParam (URI.unRText -> key) (URI.unRText -> val) ->
-          if key == k
-            then Just val
-            else Nothing
-        _ -> Nothing
-    uriHost u =
-      fmap (URI.unRText . URI.authHost) (URI.uriAuthority u)
diff --git a/src/app/Neuron/Zettelkasten/Query/Theme.hs b/src/app/Neuron/Zettelkasten/Query/Theme.hs
deleted file mode 100644
--- a/src/app/Neuron/Zettelkasten/Query/Theme.hs
+++ /dev/null
@@ -1,31 +0,0 @@
-{-# LANGUAGE DataKinds #-}
-{-# LANGUAGE DeriveAnyClass #-}
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE FlexibleContexts #-}
-{-# LANGUAGE TypeFamilies #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
-module Neuron.Zettelkasten.Query.Theme where
-
-import Data.Aeson (ToJSON)
-import Data.Default
-import Relude
-
-data ZettelsView = ZettelsView
-  { zettelsViewLinkView :: LinkView,
-    zettelsViewGroupByTag :: Bool
-  }
-  deriving (Eq, Show, Ord, Generic, ToJSON)
-
-type ZettelView = LinkView
-
-data LinkView = LinkView
-  { linkViewShowDate :: Bool
-  }
-  deriving (Eq, Show, Ord, Generic, ToJSON)
-
-instance Default LinkView where
-  def = LinkView False
-
-instance Default ZettelsView where
-  def = ZettelsView def False
diff --git a/src/app/Neuron/Zettelkasten/Query/View.hs b/src/app/Neuron/Zettelkasten/Query/View.hs
deleted file mode 100644
--- a/src/app/Neuron/Zettelkasten/Query/View.hs
+++ /dev/null
@@ -1,141 +0,0 @@
-{-# LANGUAGE FlexibleContexts #-}
-{-# LANGUAGE GADTs #-}
-{-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE QuasiQuotes #-}
-{-# LANGUAGE Rank2Types #-}
-{-# LANGUAGE RecordWildCards #-}
-{-# LANGUAGE ScopedTypeVariables #-}
-{-# LANGUAGE TypeApplications #-}
-{-# LANGUAGE ViewPatterns #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
-module Neuron.Zettelkasten.Query.View
-  ( renderQueryLink,
-    renderZettelLink,
-  )
-where
-
-import Control.Monad.Except
-import Data.Default
-import Data.Dependent.Sum
-import qualified Data.Map.Strict as Map
-import Data.Some
-import Data.TagTree (Tag (..), TagNode (..), constructTag, foldTagTree, tagMatchAny, tagTree)
-import qualified Data.Text as T
-import Data.Tree
-import Lucid
-import Lucid.Base (makeAttribute)
-import Neuron.Web.Route (Route (..), routeUrlRelWithQuery)
-import Neuron.Zettelkasten.Query
-import Neuron.Zettelkasten.Query.Error (QueryResultError (..))
-import Neuron.Zettelkasten.Query.Theme (LinkView (..), ZettelsView (..))
-import Neuron.Zettelkasten.Zettel
-import Relude
-import qualified Rib
-import Text.URI.QQ (queryKey)
-
--- | Render the custom view for the given evaluated query
-renderQueryLink :: forall m. (MonadError QueryResultError m) => DSum Query Identity -> m (Html ())
-renderQueryLink = \case
-  Query_ZettelByID zid _mconn :=> Identity mres ->
-    case mres of
-      Nothing -> throwError $ QueryResultError_NoSuchZettel zid
-      Just res ->
-        pure $ renderZettelLink Nothing res
-  q@(Query_ZettelsByTag pats _mconn view) :=> Identity res -> pure $ do
-    toHtml $ Some q
-    case zettelsViewGroupByTag view of
-      False ->
-        -- Render a list of links
-        renderZettelLinks (zettelsViewLinkView view) res
-      True ->
-        forM_ (Map.toList $ groupZettelsByTagsMatching pats res) $ \(tag, zettelGrp) -> do
-          span_ [class_ "ui basic pointing below grey label"] $ do
-            i_ [class_ "tag icon"] mempty
-            toHtml $ unTag tag
-          renderZettelLinks (zettelsViewLinkView view) zettelGrp
-  q@(Query_Tags _) :=> Identity res -> pure $ do
-    -- Render a list of tags
-    toHtml $ Some q
-    renderTagTree $ foldTagTree $ tagTree res
-  where
-    -- TODO: Instead of doing this here, group the results in runQuery itself.
-    groupZettelsByTagsMatching pats matches =
-      fmap sortZettelsReverseChronological $ Map.fromListWith (<>) $ flip concatMap matches $ \z ->
-        flip concatMap (zettelTags z) $ \t -> [(t, [z]) | tagMatchAny pats t]
-    renderZettelLinks :: LinkView -> [Zettel] -> Html ()
-    renderZettelLinks ltheme zs =
-      ul_ $ do
-        forM_ zs $ \z ->
-          li_ $ renderZettelLink (Just ltheme) z
-
--- | Render a link to an individual zettel.
-renderZettelLink :: forall m. Monad m => Maybe LinkView -> Zettel -> HtmlT m ()
-renderZettelLink (fromMaybe def -> LinkView {..}) Zettel {..} = do
-  let zurl = Rib.routeUrlRel $ Route_Zettel zettelID
-      mextra =
-        if linkViewShowDate
-          then case zettelDay of
-            Just day ->
-              Just $ toHtml $ show @Text day
-            Nothing ->
-              Nothing
-          else Nothing
-  span_ [class_ "zettel-link-container"] $ do
-    forM_ mextra $ \extra ->
-      span_ [class_ "extra"] extra
-    let linkTooltip =
-          if null zettelTags
-            then Nothing
-            else Just $ "Tags: " <> T.intercalate "; " (unTag <$> zettelTags)
-    span_ ([class_ "zettel-link"] <> withTooltip linkTooltip) $ do
-      a_ [href_ zurl] $ toHtml zettelTitle
-  where
-    withTooltip :: Maybe Text -> [Attribute]
-    withTooltip = \case
-      Nothing -> []
-      Just s ->
-        [ makeAttribute "data-tooltip" s,
-          makeAttribute "data-inverted" "",
-          makeAttribute "data-position" "right center"
-        ]
-
--- |  Render a tag tree along with the count of zettels tagged with it
-renderTagTree :: forall m. Monad m => Forest (NonEmpty TagNode, Natural) -> HtmlT m ()
-renderTagTree t =
-  div_ [class_ "tag-tree"] $
-    renderForest mempty t
-  where
-    renderForest :: [TagNode] -> Forest (NonEmpty TagNode, Natural) -> HtmlT m ()
-    renderForest ancestors forest =
-      ul_ $ do
-        forM_ forest $ \tree -> do
-          li_ $ renderTree ancestors tree
-    renderTree :: [TagNode] -> Tree (NonEmpty TagNode, Natural) -> HtmlT m ()
-    renderTree ancestors (Node (tagNode, count) children) = do
-      renderTag ancestors (tagNode, count)
-      whenNotNull children $
-        renderForest (ancestors <> toList tagNode) . toList
-    renderTag :: [TagNode] -> (NonEmpty TagNode, Natural) -> HtmlT m ()
-    renderTag ancestors (tagNode, count) = do
-      div_ [class_ "node"] $ do
-        let Tag tag = constructTag $ maybe tagNode (<> tagNode) $ nonEmpty ancestors
-            attrs = case count of
-              0 ->
-                [class_ "inactive"]
-              _ ->
-                [ href_ $ routeUrlRelWithQuery Route_Search [queryKey|tag|] tag,
-                  title_ $ show count <> " zettels tagged"
-                ]
-        a_ attrs $ renderTagNode tagNode
-    renderTagNode :: NonEmpty TagNode -> HtmlT m ()
-    renderTagNode = \case
-      n :| (nonEmpty -> mrest) ->
-        case mrest of
-          Nothing ->
-            toHtml $ unTagNode n
-          Just rest -> do
-            toHtml $ unTagNode n
-            "/"
-            renderTagNode rest
diff --git a/src/app/Text/MMark/Extension/ReplaceLink.hs b/src/app/Text/MMark/Extension/ReplaceLink.hs
deleted file mode 100644
--- a/src/app/Text/MMark/Extension/ReplaceLink.hs
+++ /dev/null
@@ -1,25 +0,0 @@
-{-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
-module Text.MMark.Extension.ReplaceLink
-  ( replaceLink,
-  )
-where
-
-import Lucid
-import Relude
-import Relude.Extra.Map
-import Text.MMark.Extension (Extension)
-import qualified Text.MMark.Extension as Ext
-import Text.MMark.MarkdownLink
-
--- | MMark extension to replace links with some HTML.
-replaceLink :: Map MarkdownLink (Html ()) -> Extension
-replaceLink linkMap =
-  Ext.inlineRender $ \f -> \case
-    inline@(Ext.Link inner uri _title) ->
-      MarkdownLink (Ext.asPlainText inner) uri
-        & flip lookup linkMap
-        & fromMaybe (f inline)
-    inline ->
-      f inline
diff --git a/src/app/Text/MMark/Extension/SetTableClass.hs b/src/app/Text/MMark/Extension/SetTableClass.hs
deleted file mode 100644
--- a/src/app/Text/MMark/Extension/SetTableClass.hs
+++ /dev/null
@@ -1,17 +0,0 @@
-{-# LANGUAGE NoImplicitPrelude #-}
-
-module Text.MMark.Extension.SetTableClass
-  ( setTableClass,
-  )
-where
-
-import Lucid
-import Relude
-import Text.MMark.Extension (Block (Table), Extension)
-import qualified Text.MMark.Extension as Ext
-
-setTableClass :: Text -> Extension
-setTableClass tableClass = Ext.blockRender $ \old block ->
-  case block of
-    table@(Table _ _) -> with (old table) [class_ tableClass]
-    other -> old other
diff --git a/src/app/Text/MMark/MarkdownLink.hs b/src/app/Text/MMark/MarkdownLink.hs
deleted file mode 100644
--- a/src/app/Text/MMark/MarkdownLink.hs
+++ /dev/null
@@ -1,40 +0,0 @@
-{-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
-module Text.MMark.MarkdownLink
-  ( MarkdownLink (..),
-    extractLinks,
-  )
-where
-
-import Control.Foldl (Fold (..))
-import qualified Data.Set as Set
-import Relude
-import Text.MMark (MMark, runScanner)
-import Text.MMark.Extension (Inline (..))
-import qualified Text.MMark.Extension as Ext
-import qualified Text.URI as URI
-
-data MarkdownLink = MarkdownLink
-  { markdownLinkText :: Text,
-    markdownLinkUri :: URI.URI
-  }
-  deriving (Eq, Ord)
-
--- | Extract all links from the Markdown document
-extractLinks :: MMark -> [MarkdownLink]
-extractLinks = Set.toList . Set.fromList . flip runScanner (Fold go [] id)
-  where
-    go acc blk = acc <> concatMap f (relevantInlines blk)
-    f = \case
-      Link inner uri _title ->
-        [MarkdownLink (Ext.asPlainText inner) uri]
-      _ ->
-        []
-    relevantInlines = \case
-      Ext.Naked xs -> toList xs
-      Ext.Paragraph xs -> toList xs
-      Ext.Blockquote xs -> concatMap relevantInlines xs
-      Ext.OrderedList _ xs -> concat $ concatMap (fmap relevantInlines) xs
-      Ext.UnorderedList xs -> concat $ concatMap (fmap relevantInlines) xs
-      _ -> []
diff --git a/src/app/Text/URI/Util.hs b/src/app/Text/URI/Util.hs
deleted file mode 100644
--- a/src/app/Text/URI/Util.hs
+++ /dev/null
@@ -1,30 +0,0 @@
-{-# LANGUAGE DataKinds #-}
-{-# LANGUAGE FlexibleContexts #-}
-{-# LANGUAGE LambdaCase #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE TypeFamilies #-}
-{-# LANGUAGE ViewPatterns #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
-module Text.URI.Util where
-
-import Relude
-import qualified Text.URI as URI
-
-getQueryParam :: URI.RText 'URI.QueryKey -> URI.URI -> Maybe Text
-getQueryParam k uri =
-  listToMaybe $ catMaybes $ flip fmap (URI.uriQuery uri) $ \case
-    URI.QueryFlag _ -> Nothing
-    URI.QueryParam key (URI.unRText -> val) ->
-      if key == k
-        then Just val
-        else Nothing
-
-hasQueryFlag :: URI.RText 'URI.QueryKey -> URI.URI -> Bool
-hasQueryFlag k uri =
-  fromMaybe False $ listToMaybe $ catMaybes $ flip fmap (URI.uriQuery uri) $ \case
-    URI.QueryFlag key ->
-      if key == k
-        then Just True
-        else Nothing
-    _ -> Nothing
diff --git a/src/lib/Data/Graph/Labelled.hs b/src/lib/Data/Graph/Labelled.hs
--- a/src/lib/Data/Graph/Labelled.hs
+++ b/src/lib/Data/Graph/Labelled.hs
@@ -9,15 +9,22 @@
     mkGraphFrom,
 
     -- * Querying
+    getGraph,
     findVertex,
     getVertices,
+    hasEdge,
+    edgeLabel,
 
     -- * Algorithms
     preSet,
+    preSetWithEdgeLabel,
+    preSetWithEdgeLabelMany,
     topSort,
     clusters,
     dfsForestFrom,
     dfsForestBackwards,
+    bfsForestFrom,
+    bfsForestBackwards,
     obviateRootUnlessForest,
     induceOnEdge,
   )
diff --git a/src/lib/Data/Graph/Labelled/Algorithm.hs b/src/lib/Data/Graph/Labelled/Algorithm.hs
--- a/src/lib/Data/Graph/Labelled/Algorithm.hs
+++ b/src/lib/Data/Graph/Labelled/Algorithm.hs
@@ -1,4 +1,5 @@
 {-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
 {-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE ViewPatterns #-}
@@ -15,6 +16,10 @@
 import Data.Tree (Forest, Tree (..))
 import Relude
 
+{-# INLINE getGraph #-}
+getGraph :: LabelledGraph v e -> LAM.AdjacencyMap e (VertexID v)
+getGraph (LabelledGraph g _) = g
+
 findVertex :: Ord (VertexID v) => VertexID v -> LabelledGraph v e -> Maybe v
 findVertex v lg@(LabelledGraph g _) = do
   guard $ LAM.hasVertex v g
@@ -28,11 +33,46 @@
 getVertices (LabelledGraph _ lm) =
   Map.elems lm
 
+hasEdge :: (Ord (VertexID v), Vertex v) => LabelledGraph v e -> v -> v -> Bool
+hasEdge (LabelledGraph g _) x y =
+  LAM.hasEdge (vertexID x) (vertexID y) g
+
+edgeLabel :: (Monoid e, Ord (VertexID v), Vertex v) => LabelledGraph v e -> v -> v -> Maybe e
+edgeLabel lg@(LabelledGraph g _) x y = do
+  guard $ hasEdge lg x y
+  pure $ LAM.edgeLabel (vertexID x) (vertexID y) g
+
 -- | Return the backlinks to the given vertex
 preSet :: (Vertex v, Ord (VertexID v)) => v -> LabelledGraph v e -> [v]
 preSet (vertexID -> zid) g =
   fmap (getVertex g) $ toList . LAM.preSet zid $ graph g
 
+-- | Return the preset of a vertex, considering only edges with the given label
+--
+-- WARNING: Dont' call this in a loop. For that, use preSetWithEdgeLabelMany
+preSetWithEdgeLabel ::
+  (Eq e, Monoid e, Vertex v, Ord (VertexID v)) =>
+  (e -> Bool) ->
+  v ->
+  LabelledGraph v e ->
+  [(e, v)]
+preSetWithEdgeLabel f v g =
+  let g' = LAM.transpose $ getGraph $ induceOnEdge f g
+      ns = Map.toList $ Map.findWithDefault mempty (vertexID v) $ LAM.adjacencyMap g'
+   in fmap (second (getVertex g) . swap) ns
+
+-- | Optimized version of preSetWithEdgeLabel for multiple-input vertices.
+preSetWithEdgeLabelMany ::
+  (Eq e, Monoid e, Vertex v, Ord (VertexID v)) =>
+  e ->
+  LabelledGraph v e ->
+  (v -> [v])
+preSetWithEdgeLabelMany e g =
+  -- Compute the graph to search once, and then use it multiple times via the
+  -- returned function.
+  let g' = LAM.transpose $ graph $ induceOnEdge (== e) g
+   in \(vertexID -> v) -> fmap (getVertex g) $ toList $ LAM.postSet v g'
+
 topSort :: (Vertex v, Ord (VertexID v)) => LabelledGraph v e -> Either (NonEmpty v) [v]
 topSort g =
   bimap (fmap (getVertex g)) (fmap (getVertex g))
@@ -40,6 +80,9 @@
     $ LAM.skeleton
     $ graph g
 
+-- | Returns the clusters in an ayclic graph.
+--
+-- If the graph is one cluster and that is acyclic, this will return an empty list.
 clusters :: (Vertex v, Ord (VertexID v)) => LabelledGraph v e -> [NonEmpty v]
 clusters g =
   fmap (fmap $ getVertex g) $ mothers $ LAM.skeleton $ graph g
@@ -56,6 +99,14 @@
 dfsForestBackwards fromV (LabelledGraph g' v') =
   dfsForestFrom [fromV] $ LabelledGraph (LAM.transpose g') v'
 
+bfsForestBackwards :: (Monoid e, Vertex v, Ord (VertexID v)) => v -> LabelledGraph v e -> Forest v
+bfsForestBackwards fromV (LabelledGraph g' v') =
+  bfsForestFrom [fromV] $ LabelledGraph (LAM.transpose g') v'
+
+bfsForestFrom :: (Vertex v, Ord (VertexID v)) => [v] -> LabelledGraph v e -> Forest v
+bfsForestFrom (fmap vertexID -> vs) g =
+  fmap (fmap $ getVertex g) $ Algo.bfsForest vs $ LAM.skeleton $ graph g
+
 --------------------------
 --- More general utilities
 --------------------------
@@ -78,17 +129,15 @@
     go acc = \case
       [] -> acc
       v : (Set.fromList -> vs) ->
-        let reach = reachableUndirected v g
+        let reach = reachableUndirected v
             covered = vs `Set.intersection` reach
             rest = vs `Set.difference` reach
          in go ((v :| Set.toList covered) : acc) (Set.toList rest)
-
--- | Get the vertexes reachable (regardless of direction) from the given vertex.
-reachableUndirected :: Ord a => a -> AM.AdjacencyMap a -> Set a
-reachableUndirected v =
-  Set.fromList . Algo.reachable v . toUndirected
-  where
-    toUndirected g = AM.overlay g $ AM.transpose g
+    -- Vertices reachable from `v` regardless of direction.
+    reachableUndirected v =
+      Set.fromList $ Algo.reachable v gUndirected
+    -- The undirected version of g
+    gUndirected = AM.overlay g $ AM.transpose g
 
 motherVertices :: Ord a => AM.AdjacencyMap a -> [a]
 motherVertices =
diff --git a/src/lib/Data/Graph/Labelled/Type.hs b/src/lib/Data/Graph/Labelled/Type.hs
--- a/src/lib/Data/Graph/Labelled/Type.hs
+++ b/src/lib/Data/Graph/Labelled/Type.hs
@@ -1,4 +1,7 @@
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveGeneric #-}
 {-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE StandaloneDeriving #-}
 {-# LANGUAGE TypeFamilies #-}
@@ -8,6 +11,8 @@
 module Data.Graph.Labelled.Type where
 
 import qualified Algebra.Graph.Labelled.AdjacencyMap as LAM
+import Data.Aeson
+import qualified Data.Map.Strict as Map
 import Relude
 
 -- | Instances of this class can be used as a vertex in a graph.
@@ -27,5 +32,32 @@
   { graph :: LAM.AdjacencyMap e (VertexID v),
     vertices :: Map (VertexID v) v
   }
+  deriving (Generic)
 
-deriving instance (Ord e, Show e, Show v, Ord (VertexID v), Show (VertexID v)) => Show (LabelledGraph v e)
+deriving instance (Eq e, Eq v, Eq (VertexID v)) => Eq (LabelledGraph v e)
+
+deriving instance
+  ( Ord e,
+    Show e,
+    Show v,
+    Ord (VertexID v),
+    Show (VertexID v)
+  ) =>
+  Show (LabelledGraph v e)
+
+instance (ToJSONKey (VertexID v), ToJSON v, ToJSON e) => ToJSON (LabelledGraph v e) where
+  toJSON (LabelledGraph g vs) =
+    toJSON $
+      object
+        [ "adjacencyMap" .= LAM.adjacencyMap g,
+          "vertices" .= vs
+        ]
+
+instance (FromJSONKey (VertexID v), Ord (VertexID v), Eq e, Monoid e, FromJSON v, FromJSON e) => FromJSON (LabelledGraph v e) where
+  parseJSON =
+    withObject "LabelledGraph" $ \lg ->
+      LabelledGraph
+        <$> (fmap mkGraph $ lg .: "adjacencyMap")
+        <*> lg .: "vertices"
+    where
+      mkGraph = LAM.fromAdjacencyMaps . Map.toList
diff --git a/src/lib/Data/TagTree.hs b/src/lib/Data/TagTree.hs
--- a/src/lib/Data/TagTree.hs
+++ b/src/lib/Data/TagTree.hs
@@ -23,6 +23,7 @@
 import Data.PathTree (annotatePathsWith, foldSingleParentsWith, mkTreeFromPaths)
 import qualified Data.Text as T
 import Data.Tree (Forest)
+import Data.YAML (FromYAML, ToYAML)
 import Relude
 import System.FilePattern
 import qualified Text.Megaparsec as M
@@ -31,7 +32,7 @@
 
 -- | Tag metadata field in Zettel notes
 newtype Tag = Tag {unTag :: Text}
-  deriving (Eq, Ord, Show, ToJSON, FromJSON)
+  deriving (Eq, Ord, Show, ToJSON, FromJSON, FromYAML, ToYAML)
 
 --------------
 -- Tag Pattern
@@ -41,7 +42,7 @@
 --
 -- Eg.: "foo/**" matches both "foo/bar/baz" and "foo/baz"
 newtype TagPattern = TagPattern {unTagPattern :: FilePattern}
-  deriving (Eq, Show, ToJSON)
+  deriving (Eq, Show, ToJSON, FromJSON)
 
 mkTagPattern :: Text -> TagPattern
 mkTagPattern =
diff --git a/src/lib/Neuron/Config/Type.hs b/src/lib/Neuron/Config/Type.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Config/Type.hs
@@ -0,0 +1,77 @@
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Config.Type
+  ( Config (..),
+    configFile,
+    defaultConfig,
+    mergeWithDefault,
+    getZettelFormats,
+  )
+where
+
+import Data.Aeson
+import Neuron.Reader.Type (ZettelFormat)
+import Relude hiding (readEither)
+import Text.Read (readEither)
+
+configFile :: FilePath
+configFile = "neuron.dhall"
+
+-- | Config type for @neuron.dhall@
+--
+-- See <https://neuron.zettel.page/2011701.html guide> for description of the fields.
+--
+-- TODO: Implement custom `FromDhall` instance, while using original field types
+data Config = Config
+  { aliases :: [Text],
+    author :: Maybe Text,
+    editUrl :: Maybe Text,
+    mathJaxSupport :: Bool,
+    -- TODO: This should use `NonEmpty`.
+    formats :: [Text],
+    minVersion :: Text,
+    siteBaseUrl :: Maybe Text,
+    siteTitle :: Text,
+    theme :: Text
+  }
+  deriving (Eq, Show, Generic, FromJSON, ToJSON)
+
+getZettelFormats :: MonadFail m => Config -> m (NonEmpty ZettelFormat)
+getZettelFormats Config {..} = do
+  formats' <- maybe (fail "Empty formats") pure $ nonEmpty formats
+  traverse (either (fail . toString) pure . readEither . toString) formats'
+
+defaultConfig :: Text
+defaultConfig =
+  "{ siteTitle =\
+  \   \"My Zettelkasten\" \
+  \, author =\
+  \   None Text\
+  \, siteBaseUrl =\
+  \   None Text\
+  \, editUrl =\
+  \   None Text\
+  \, theme =\
+  \   \"blue\"\
+  \, aliases =\
+  \   [] : List Text\
+  \, formats =\
+  \   [ \"markdown\" ]\
+  \, mathJaxSupport =\
+  \   True\
+  \, minVersion =\
+  \   \"0.5\" \
+  \}"
+
+-- Dhall's combine operator (`//`) allows us to merge two records,
+-- effectively merging the record with defaults with the user record.
+mergeWithDefault :: Text -> Text
+mergeWithDefault userConfig =
+  defaultConfig <> " // " <> userConfig
diff --git a/src/lib/Neuron/Orphans.hs b/src/lib/Neuron/Orphans.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Orphans.hs
@@ -0,0 +1,16 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module Neuron.Orphans where
+
+import Data.Aeson
+import Relude
+import Text.URI (URI, mkURI, render)
+
+instance ToJSON URI where
+  toJSON = toJSON @Text . render
+
+instance FromJSON URI where
+  parseJSON = (either (fail . displayException) pure) . mkURI <=< parseJSON @Text
diff --git a/src/lib/Neuron/Reader/Markdown.hs b/src/lib/Neuron/Reader/Markdown.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Reader/Markdown.hs
@@ -0,0 +1,182 @@
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TupleSections #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Reader.Markdown
+  ( parseMarkdown,
+  )
+where
+
+import qualified Commonmark as CM
+import qualified Commonmark.Blocks as CM
+import qualified Commonmark.Extensions as CE
+import qualified Commonmark.Inlines as CM
+import qualified Commonmark.Pandoc as CP
+import qualified Commonmark.Tag
+import Commonmark.TokParsers (noneOfToks, symbol)
+import Commonmark.Tokens (TokType (..))
+import Control.Monad.Combinators (manyTill)
+import Control.Monad.Except
+import Data.Tagged (Tagged (..))
+import qualified Data.YAML as YAML
+import Neuron.Orphans ()
+import Neuron.Reader.Type (ZettelParseError, ZettelReader)
+import Neuron.Zettelkasten.Zettel.Meta (Meta)
+import Relude hiding (show, traceShowId)
+import qualified Text.Megaparsec as M
+import qualified Text.Megaparsec.Char as M
+import Text.Megaparsec.Simple
+import qualified Text.Pandoc.Builder as B
+import Text.Pandoc.Definition (Pandoc (..))
+import qualified Text.Parsec as P
+import Text.Show
+
+-- | Parse Markdown document, along with the YAML metadata block in it.
+--
+-- We are not using the Pandoc AST "metadata" field (as it is not clear whether
+-- we actually need it), and instead directly decoding the metadata as Haskell
+-- object.
+parseMarkdown :: ZettelReader
+parseMarkdown fn s = do
+  (metaVal, markdown) <-
+    first (Tagged . ("Unable to determine YAML region: " <>)) $
+      partitionMarkdown fn s
+  v <-
+    first (Tagged . toText . show) $
+      commonmarkPandocWith neuronSpec fn markdown
+  meta <- traverse (parseMeta fn) metaVal
+  pure (meta, Pandoc mempty $ B.toList (CP.unCm v))
+  where
+    -- NOTE: HsYAML parsing is rather slow due to its use of DList.
+    -- See https://github.com/haskell-hvr/HsYAML/issues/40
+    parseMeta :: FilePath -> Text -> Either ZettelParseError Meta
+    parseMeta n v = do
+      let raw = encodeUtf8 v
+      let mkError (loc, emsg) =
+            Tagged $ toText $ n <> ":" <> YAML.prettyPosWithSource loc raw " error" <> emsg
+      first mkError $ YAML.decode1 raw
+    -- Like commonmarkWith, but parses directly into the Pandoc AST.
+    commonmarkPandocWith ::
+      CM.SyntaxSpec (Either P.ParseError) (CP.Cm () B.Inlines) (CP.Cm () B.Blocks) ->
+      String ->
+      Text ->
+      Either P.ParseError (CP.Cm () B.Blocks)
+    commonmarkPandocWith spec n =
+      join . CM.commonmarkWith spec n
+
+-- | Identify metadata block at the top, and split it from markdown body.
+partitionMarkdown :: FilePath -> Text -> Either Text (Maybe Text, Text)
+partitionMarkdown fn =
+  parse (M.try splitP <|> fmap (Nothing,) M.takeRest) fn
+  where
+    separatorP :: Parser ()
+    separatorP =
+      void $ M.string "---" <* M.eol
+    splitP :: Parser (Maybe Text, Text)
+    splitP = do
+      separatorP
+      a <- toText <$> manyTill M.anySingle (M.try $ M.eol *> separatorP)
+      b <- M.takeRest
+      pure (Just a, b)
+
+neuronSpec ::
+  ( Monad m,
+    CM.IsBlock il bl,
+    CM.IsInline il,
+    Typeable m,
+    Typeable il,
+    Typeable bl,
+    CE.HasEmoji il,
+    CE.HasStrikethrough il,
+    CE.HasPipeTable il bl,
+    CE.HasTaskList il bl,
+    CM.ToPlainText il,
+    CE.HasFootnote il bl,
+    CE.HasMath il,
+    CE.HasDefinitionList il bl,
+    CE.HasDiv bl
+  ) =>
+  CM.SyntaxSpec m il bl
+neuronSpec =
+  mconcat
+    [ wrappedLinkSpec angleBracketLinkP,
+      wrappedLinkSpec wikiLinkP,
+      gfmExtensionsSansEmoji,
+      CE.footnoteSpec,
+      CE.mathSpec,
+      CE.smartPunctuationSpec,
+      CE.definitionListSpec,
+      CE.attributesSpec,
+      CE.rawAttributeSpec,
+      CE.fencedDivSpec,
+      CM.defaultSyntaxSpec {CM.syntaxBlockSpecs = defaultBlockSpecsSansRawHtml}
+    ]
+  where
+    -- Emoji extension introduces ghcjs linker issues
+    gfmExtensionsSansEmoji =
+      CE.strikethroughSpec
+        <> CE.pipeTableSpec
+        <> CE.autoIdentifiersSpec
+        <> CE.taskListSpec
+
+-- | Convert the given wrapped link to a `B.Link`.
+wrappedLinkSpec ::
+  (Monad m, CM.IsBlock il bl, CM.IsInline il) =>
+  (P.ParsecT [CM.Tok] (CM.IPState m) (StateT Commonmark.Tag.Enders m) [CM.Tok]) ->
+  CM.SyntaxSpec m il bl
+wrappedLinkSpec linkP =
+  mempty
+    { CM.syntaxInlineParsers = [pLink linkP]
+    }
+  where
+    pLink ::
+      (Monad m, CM.IsInline il) =>
+      (P.ParsecT [CM.Tok] (CM.IPState m) (StateT Commonmark.Tag.Enders m) [CM.Tok]) ->
+      CM.InlineParser m il
+    pLink p = P.try $ do
+      x <- p
+      let url = CM.untokenize x
+          title = ""
+      pure $! CM.link url title $ CM.str url
+
+angleBracketLinkP :: Monad m => P.ParsecT [CM.Tok] s m [CM.Tok]
+angleBracketLinkP = do
+  void $ symbol '<'
+  x <- some (noneOfToks [Symbol '>', Spaces, UnicodeSpace, LineEnd])
+  void $ symbol '>'
+  pure x
+
+wikiLinkP :: Monad m => P.ParsecT [CM.Tok] s m [CM.Tok]
+wikiLinkP = do
+  void $ symbol '[' >> symbol '['
+  x <- some (noneOfToks [Symbol ']', Spaces, UnicodeSpace, LineEnd])
+  void $ symbol ']' >> symbol ']'
+  pure x
+
+-- rawHtmlSpec eats angle bracket links as html tags
+defaultBlockSpecsSansRawHtml :: (Monad m, CM.IsBlock il bl) => [CM.BlockSpec m il bl]
+defaultBlockSpecsSansRawHtml =
+  [ CM.indentedCodeSpec,
+    CM.fencedCodeSpec,
+    CM.blockQuoteSpec,
+    CM.atxHeadingSpec,
+    CM.setextHeadingSpec,
+    CM.thematicBreakSpec,
+    CM.listItemSpec (CM.bulletListMarker <|> CM.orderedListMarker),
+    myRawHtmlSpec,
+    CM.attributeSpec
+  ]
+
+-- | Like `CM.rawHtmlSpec` but lets zettel style links pass through.
+myRawHtmlSpec ::
+  (Monad m, CM.IsBlock il bl) =>
+  CM.BlockSpec m il bl
+myRawHtmlSpec =
+  -- TODO: Ideally we should use a more restrictive parsers; one that allows known safe HTML tags
+  -- Although, this prevents the user from naming their zettels say "div.md"
+  CM.rawHtmlSpec {CM.blockStart = P.notFollowedBy angleBracketLinkP >> CM.blockStart CM.rawHtmlSpec}
diff --git a/src/lib/Neuron/Reader/Type.hs b/src/lib/Neuron/Reader/Type.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Reader/Type.hs
@@ -0,0 +1,60 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Reader.Type
+  ( ZettelReader,
+    ZettelParseError,
+    ZettelFormat (..),
+    zettelFormatToExtension,
+  )
+where
+
+import Data.Aeson
+import Data.Tagged
+import Neuron.Zettelkasten.Zettel.Meta
+import Relude hiding (readEither, show)
+import Text.Pandoc.Definition (Pandoc)
+import Text.Read
+import Prelude (show)
+
+type ZettelReader = FilePath -> Text -> Either ZettelParseError (Maybe Meta, Pandoc)
+
+type ZettelParseError = Tagged "ZettelParserError" Text
+
+data ZettelFormat
+  = ZettelFormat_Markdown
+  | ZettelFormat_Org
+  deriving (Eq, Ord, Generic)
+
+instance FromJSON ZettelFormat where
+  parseJSON =
+    either fail pure . readEither <=< parseJSON
+
+instance ToJSON ZettelFormat where
+  toJSON =
+    toJSON . show
+
+instance Show ZettelFormat where
+  show ZettelFormat_Markdown = "markdown"
+  show ZettelFormat_Org = "org"
+
+instance Read ZettelFormat where
+  readPrec =
+    choice
+      [ do
+          Ident "org" <- lexP
+          pure ZettelFormat_Org,
+        do
+          Ident "markdown" <- lexP
+          pure ZettelFormat_Markdown
+      ]
+
+zettelFormatToExtension :: ZettelFormat -> Text
+zettelFormatToExtension = \case
+  ZettelFormat_Markdown -> ".md"
+  ZettelFormat_Org -> ".org"
diff --git a/src/lib/Neuron/Web/Common.hs b/src/lib/Neuron/Web/Common.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Web/Common.hs
@@ -0,0 +1,30 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Web.Common where
+
+import Clay ((?), Css)
+import qualified Clay as C
+import Relude
+
+neuronCommonStyle :: Css
+neuronCommonStyle = do
+  C.important $ C.backgroundColor "#eee"
+  C.important $ C.fontFamily [bodyFont] [C.serif]
+  "h1, h2, h3, h4, h5, h6, .ui.header, .headerFont" ? do
+    C.important $ C.fontFamily [headerFont] [C.sansSerif]
+  "code, pre, tt, .monoFont" ? do
+    C.important $ C.fontFamily [monoFont, "SFMono-Regular", "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New"] [C.monospace]
+
+neuronFonts :: [Text]
+neuronFonts = [headerFont, bodyFont, monoFont]
+
+headerFont :: Text
+headerFont = "DM Serif Text"
+
+bodyFont :: Text
+bodyFont = "DM Sans"
+
+monoFont :: Text
+monoFont = "DM Mono"
diff --git a/src/lib/Neuron/Web/Manifest.hs b/src/lib/Neuron/Web/Manifest.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Web/Manifest.hs
@@ -0,0 +1,94 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Web.Manifest
+  ( Manifest,
+    manifestPatterns,
+    mkManifest,
+    renderManifest,
+  )
+where
+
+import qualified Data.Set as Set
+import Reflex.Dom.Core
+import Relude
+
+data Manifest = Manifest
+  { manifestFavicons :: Maybe Favicons,
+    manifestWebAppManifest :: Maybe FilePath
+  }
+  deriving (Eq)
+
+data Favicons = Favicons
+  { faviconsDefault :: FilePath,
+    faviconsAlts :: [FilePath],
+    faviconsAppleTouch :: Maybe FilePath
+  }
+  deriving (Eq)
+
+mkManifest :: [FilePath] -> Manifest
+mkManifest (Set.fromList -> files) =
+  Manifest
+    { manifestFavicons = mkFavicons,
+      manifestWebAppManifest = lookupSet webmanifestFile files
+    }
+  where
+    mkFavicons =
+      case filter (`Set.member` files) favicons of
+        (ico : alts) ->
+          Just $
+            Favicons
+              { faviconsDefault = ico,
+                faviconsAlts = alts,
+                faviconsAppleTouch = lookupSet appleTouchIcon files
+              }
+        [] ->
+          Nothing
+    lookupSet x s =
+      if Set.member x s
+        then Just x
+        else Nothing
+
+renderManifest :: DomBuilder t m => Manifest -> m ()
+renderManifest Manifest {..} = do
+  linkRel "manifest" `mapM_` manifestWebAppManifest
+  case manifestFavicons of
+    Nothing ->
+      linkRel "icon" defaultFaviconUrl
+    Just Favicons {..} -> do
+      linkRel "icon" faviconsDefault
+      linkRel "alternate icon" `mapM_` faviconsAlts
+      linkRel "apple-touch-icon" `mapM_` faviconsAppleTouch
+  where
+    defaultFaviconUrl :: String
+    defaultFaviconUrl =
+      "https://raw.githubusercontent.com/srid/neuron/master/assets/neuron.svg"
+    linkRel rel path =
+      -- crossorigin="use-credentials"
+      elAttr
+        "link"
+        ( "rel" =: rel
+            <> "href" =: toText path
+            -- The use-credentials value must be used when fetching a manifest that requires credentials, even if the file is from the same origin.
+            -- cf. https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/crossorigin
+            -- cf. https://thatemil.com/blog/2018/02/21/pwa-basic-auth/
+            <> (if rel == "manifest" then "crossorigin" =: "use-credentials" else mempty)
+        )
+        blank
+
+manifestPatterns :: [FilePath]
+manifestPatterns =
+  favicons <> [appleTouchIcon, webmanifestFile]
+
+-- | Supported favicons, in order of preference
+favicons :: [FilePath]
+favicons = fmap ("static/favicon." <>) ["svg", "png", "ico", "jpg", "jpeg"]
+
+appleTouchIcon :: FilePath
+appleTouchIcon = "static/apple-touch-icon.png"
+
+webmanifestFile :: FilePath
+webmanifestFile = "static/manifest.webmanifest"
diff --git a/src/lib/Neuron/Web/Query/View.hs b/src/lib/Neuron/Web/Query/View.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Web/Query/View.hs
@@ -0,0 +1,205 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE Rank2Types #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Web.Query.View
+  ( renderQueryResult,
+    renderZettelLink,
+    renderZettelLinkIDOnly,
+    zettelUrl,
+    tagUrl,
+    style,
+  )
+where
+
+import qualified Clay as C
+import Clay ((?), Css, em)
+import Control.Monad.Except
+import Data.Default
+import Data.Dependent.Sum
+import qualified Data.Map.Strict as Map
+import Data.Some
+import Data.TagTree (Tag (..), TagNode (..), TagPattern (..), constructTag, foldTagTree, tagMatchAny, tagTree)
+import qualified Data.Text as T
+import Data.Tree
+import Neuron.Web.Route
+import Neuron.Web.Widget
+import Neuron.Zettelkasten.Connection
+import Neuron.Zettelkasten.ID
+import Neuron.Zettelkasten.Query.Theme (LinkView (..), ZettelsView (..))
+import Neuron.Zettelkasten.Zettel
+import Reflex.Dom.Core hiding (count, tag)
+import Relude
+
+-- | Render the query results.
+renderQueryResult ::
+  DomBuilder t m => DSum ZettelQuery Identity -> NeuronWebT t m ()
+renderQueryResult = \case
+  ZettelQuery_ZettelByID _zid (fromMaybe def -> conn) :=> Identity target -> do
+    renderZettelLink (Just conn) Nothing target
+  q@(ZettelQuery_ZettelsByTag pats (fromMaybe def -> conn) view) :=> Identity res -> do
+    el "section" $ do
+      renderQuery $ Some q
+      case zettelsViewGroupByTag view of
+        False ->
+          el "ul" $ forM_ res $ \z -> do
+            el "li" $
+              renderZettelLink (Just conn) (Just $ zettelsViewLinkView view) z
+        True ->
+          forM_ (Map.toList $ groupZettelsByTagsMatching pats res) $ \(tag, zettelGrp) -> do
+            el "section" $ do
+              elClass "span" "ui basic pointing below grey label" $ do
+                semanticIcon "tag"
+                text $ unTag tag
+              el "ul" $ forM_ zettelGrp $ \z ->
+                el "li" $
+                  renderZettelLink (Just conn) (Just $ zettelsViewLinkView view) z
+  q@(ZettelQuery_Tags _) :=> Identity res -> do
+    el "section" $ do
+      renderQuery $ Some q
+      renderTagTree $ foldTagTree $ tagTree res
+  where
+    -- TODO: Instead of doing this here, group the results in runQuery itself.
+    groupZettelsByTagsMatching pats matches =
+      fmap sortZettelsReverseChronological $ Map.fromListWith (<>) $ flip concatMap matches $ \z ->
+        flip concatMap (zettelTags z) $ \t -> [(t, [z]) | tagMatchAny pats t]
+
+renderQuery :: DomBuilder t m => Some ZettelQuery -> m ()
+renderQuery someQ =
+  elAttr "div" ("class" =: "ui horizontal divider" <> "title" =: "Neuron ZettelQuery") $ do
+    case someQ of
+      Some (ZettelQuery_ZettelByID _ _) ->
+        blank
+      Some (ZettelQuery_ZettelsByTag [] _mconn _mview) ->
+        text "All zettels"
+      Some (ZettelQuery_ZettelsByTag (fmap unTagPattern -> pats) _mconn _mview) -> do
+        let qs = toText $ intercalate ", " pats
+            desc = toText $ "Zettels tagged '" <> qs <> "'"
+        elAttr "span" ("class" =: "ui basic pointing below black label" <> "title" =: desc) $ do
+          semanticIcon "tags"
+          text qs
+      Some (ZettelQuery_Tags []) ->
+        text "All tags"
+      Some (ZettelQuery_Tags (fmap unTagPattern -> pats)) -> do
+        let qs = toText $ intercalate ", " pats
+        text $ "Tags matching '" <> qs <> "'"
+
+-- | Render a link to an individual zettel.
+renderZettelLink :: DomBuilder t m => Maybe Connection -> Maybe LinkView -> Zettel -> NeuronWebT t m ()
+renderZettelLink conn (fromMaybe def -> linkView) Zettel {..} = do
+  let connClass = show <$> conn
+      rawClass = either (const $ Just "raw") (const Nothing) zettelError
+      mextra =
+        case linkView of
+          LinkView_Default ->
+            Nothing
+          LinkView_ShowDate ->
+            elTime <$> zettelDay
+          LinkView_ShowID ->
+            Just $ el "tt" $ text $ zettelIDText zettelID
+      classes :: [Text] = catMaybes $ [Just "zettel-link-container"] <> [connClass, rawClass]
+  elClass "span" (T.intercalate " " classes) $ do
+    forM_ mextra $ \extra ->
+      elAttr "span" ("class" =: "extra monoFont" <> noSnippet) $ do
+        extra
+        -- The extra space is so that double clicking on this extra text
+        -- doesn't select the title next.
+        text " "
+    let linkTooltip =
+          if null zettelTags
+            then Nothing
+            else Just $ "Tags: " <> T.intercalate "; " (unTag <$> zettelTags)
+    elAttr "span" ("class" =: "zettel-link" <> withTooltip linkTooltip) $ do
+      neuronRouteLink (Some $ Route_Zettel zettelID) mempty $ text zettelTitle
+  where
+    -- Prevent this element from appearing in Google search results
+    -- https://developers.google.com/search/reference/robots_meta_tag#data-nosnippet-attr
+    noSnippet :: Map Text Text
+    noSnippet = "data-nosnippet" =: ""
+    withTooltip :: Maybe Text -> Map Text Text
+    withTooltip = \case
+      Nothing -> mempty
+      Just s ->
+        ( "data-tooltip" =: s
+            <> "data-inverted" =: ""
+            <> "data-position" =: "right center"
+        )
+
+-- | Like `renderZettelLink` but when we only have ID in hand.
+renderZettelLinkIDOnly :: DomBuilder t m => ZettelID -> NeuronWebT t m ()
+renderZettelLinkIDOnly zid =
+  elClass "span" "zettel-link-container" $ do
+    elClass "span" "zettel-link" $ do
+      neuronRouteLink (Some $ Route_Zettel zid) mempty $ text $ zettelIDText zid
+
+renderTagTree :: forall t m. DomBuilder t m => Forest (NonEmpty TagNode, Natural) -> m ()
+renderTagTree t =
+  divClass "tag-tree" $
+    renderForest mempty t
+  where
+    renderForest :: [TagNode] -> Forest (NonEmpty TagNode, Natural) -> m ()
+    renderForest ancestors forest =
+      el "ul" $ do
+        forM_ forest $ \tree ->
+          el "li" $ renderTree ancestors tree
+    renderTree :: [TagNode] -> Tree (NonEmpty TagNode, Natural) -> m ()
+    renderTree ancestors (Node (tagNode, count) children) = do
+      renderTag ancestors (tagNode, count)
+      renderForest (ancestors <> toList tagNode) $ toList children
+    renderTag :: [TagNode] -> (NonEmpty TagNode, Natural) -> m ()
+    renderTag ancestors (tagNode, count) = do
+      let tag = constructTag $ maybe tagNode (<> tagNode) $ nonEmpty ancestors
+          tit = show count <> " zettels tagged"
+          cls = bool "" "inactive" $ count == 0
+      divClass "node" $ do
+        elAttr "a" ("class" =: cls <> "title" =: tit <> "href" =: tagUrl tag) $ do
+          text $ renderTagNode tagNode
+    renderTagNode :: NonEmpty TagNode -> Text
+    renderTagNode = \case
+      n :| (nonEmpty -> mrest) ->
+        case mrest of
+          Nothing ->
+            unTagNode n
+          Just rest ->
+            unTagNode n <> "/" <> renderTagNode rest
+
+-- TODO: not using Rib for ghcjs, but factorize this
+zettelUrl :: ZettelID -> Text
+zettelUrl zid =
+  zettelIDText zid <> ".html"
+
+tagUrl :: Tag -> Text
+tagUrl (Tag s) =
+  "search.html?tag=" <> s
+
+style :: Css
+style = do
+  zettelLinkCss
+  "div.tag-tree" ? do
+    "div.node" ? do
+      C.fontWeight C.bold
+      "a.inactive" ? do
+        C.color "#555"
+
+zettelLinkCss :: Css
+zettelLinkCss = do
+  "span.zettel-link-container span.zettel-link a" ? do
+    C.fontWeight C.bold
+    C.textDecoration C.none
+  "span.zettel-link-container span.extra" ? do
+    C.color C.auto
+  "span.zettel-link-container.folgezettel::after" ? do
+    C.paddingLeft $ em 0.3
+    C.content $ C.stringContent "ᛦ"
+  "span.zettel-link-container.raw" ? do
+    C.border C.solid (C.px 1) C.red
+  "[data-tooltip]:after" ? do
+    C.fontSize $ em 0.7
diff --git a/src/lib/Neuron/Web/Route.hs b/src/lib/Neuron/Web/Route.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Web/Route.hs
@@ -0,0 +1,62 @@
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Neuron's route and its config
+module Neuron.Web.Route where
+
+import Control.Monad.Reader
+import Data.Some
+import Neuron.Zettelkasten.ID
+import Neuron.Zettelkasten.Zettel
+import Reflex.Dom.Core
+import Relude
+
+data Route a where
+  Route_Redirect :: ZettelID -> Route ZettelID
+  -- ZIndex takes a report of all errors in the zettelkasten.
+  -- `Left` is skipped zettels; and Right is valid zettels with invalid query links.
+  Route_ZIndex :: Route (Map ZettelID ZettelError)
+  -- | Takes search JS code as render data
+  Route_Search :: Route Text
+  Route_Zettel :: ZettelID -> Route ZettelC
+
+data RouteConfig t m = RouteConfig
+  { -- | Whether the view is being rendered for static HTML generation
+    routeConfigStaticallyGenerated :: Bool,
+    -- | How to render a web route.
+    routeConfigRouteLink :: DomBuilder t m => Some Route -> Map Text Text -> m () -> m (),
+    -- | Get the URL for a web route as plain text
+    routeConfigRouteURL :: Some Route -> Text
+  }
+
+type NeuronWebT t m = ReaderT (RouteConfig t m) m
+
+runNeuronWeb :: RouteConfig t m -> NeuronWebT t m a -> m a
+runNeuronWeb cfg = flip runReaderT cfg
+
+whenStaticallyGenerated :: Monad m => NeuronWebT t m () -> NeuronWebT t m ()
+whenStaticallyGenerated f = do
+  staticGen <- asks routeConfigStaticallyGenerated
+  when staticGen f
+
+neuronRouteLink :: DomBuilder t m => Some Route -> Map Text Text -> m () -> NeuronWebT t m ()
+neuronRouteLink someR attrs w = do
+  f <- asks routeConfigRouteLink
+  lift $ f someR attrs w
+
+neuronRouteURL :: Monad m => Some Route -> NeuronWebT t m Text
+neuronRouteURL someR = do
+  f <- asks routeConfigRouteURL
+  pure $ f someR
+
+routeTitle' :: a -> Route a -> Text
+routeTitle' v = \case
+  Route_Redirect _ -> "Redirecting..."
+  Route_ZIndex -> "Zettel Index"
+  Route_Search -> "Search"
+  Route_Zettel _ ->
+    either zettelTitle zettelTitle v
diff --git a/src/lib/Neuron/Web/Theme.hs b/src/lib/Neuron/Web/Theme.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Web/Theme.hs
@@ -0,0 +1,112 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | HTML & CSS
+module Neuron.Web.Theme
+  ( Theme (..),
+    mkTheme,
+    themeCss,
+    semanticColor,
+    themeIdentifier,
+  )
+where
+
+import Clay ((?), Css, rgb, rgba)
+import qualified Clay as C
+import Data.Text (toLower)
+import Relude
+
+-- | Neuron color theme
+--
+-- Each theme corresponds to the color supported by Semantic UI
+-- https://semantic-ui.com/usage/theming.html#sitewide-defaults
+data Theme
+  = Teal
+  | Brown
+  | Red
+  | Orange
+  | Yellow
+  | Olive
+  | Green
+  | Blue
+  | Violet
+  | Purple
+  | Pink
+  | Grey
+  | Black
+  deriving (Eq, Show, Enum, Bounded)
+
+-- | Make Theme from Semantic UI color name
+mkTheme :: Text -> Theme
+mkTheme s =
+  fromMaybe (error $ "Unsupported theme: " <> s)
+    $ listToMaybe
+    $ catMaybes
+    $ flip fmap [minBound .. maxBound]
+    $ \theme ->
+      if s == semanticColor theme
+        then Just theme
+        else Nothing
+
+-- | Convert Theme to Semantic UI color name
+semanticColor :: Theme -> Text
+semanticColor = toLower . show @Text
+
+themeIdentifier :: Theme -> String
+themeIdentifier theme =
+  "neuron-theme-default-" <> toString (semanticColor theme)
+
+themeCss :: Css
+themeCss = do
+  forM_ [minBound .. maxBound] $ \(theme :: Theme) -> do
+    let selector = fromString $ "div#" <> themeIdentifier theme
+        textColor = withRgb theme rgb
+        backgroundColor = withRgb theme rgba 0.1
+    selector ? do
+      -- Zettel heading's background color
+      ".zettel-content h1" ? do
+        C.backgroundColor backgroundColor
+      -- Zettel links
+      "span.zettel-link-container span.zettel-link a" ? do
+        C.color textColor
+      "span.zettel-link-container span.zettel-link a:hover" ? do
+        C.color C.white
+        C.backgroundColor textColor
+      -- Deemphasized items in uptree; restore link colors
+      ".deemphasized:hover" ? do
+        "div.item a:hover" ? C.important (C.color textColor)
+      -- Zettel footnote's top marging line
+      "div#footnotes" ? do
+        C.borderTopColor textColor
+
+withRgb :: Theme -> (Integer -> Integer -> Integer -> a) -> a
+withRgb theme f =
+  case theme of
+    Teal ->
+      f 0 181 173
+    Brown ->
+      f 165 103 63
+    Red ->
+      f 219 40 40
+    Orange ->
+      f 242 113 28
+    Yellow ->
+      f 251 189 8
+    Olive ->
+      f 181 204 24
+    Green ->
+      f 33 186 69
+    Blue ->
+      f 33 133 208
+    Violet ->
+      f 100 53 201
+    Purple ->
+      f 163 51 200
+    Pink ->
+      f 224 57 151
+    Grey ->
+      f 118 118 118
+    Black ->
+      f 27 28 29
diff --git a/src/lib/Neuron/Web/Widget.hs b/src/lib/Neuron/Web/Widget.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Web/Widget.hs
@@ -0,0 +1,27 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Web.Widget where
+
+import qualified Data.Text as T
+import Data.Time
+import Neuron.Zettelkasten.Zettel.Meta (formatZettelDate)
+import Reflex.Dom.Core
+import Relude
+
+-- | <time> element
+elTime :: DomBuilder t m => Day -> m ()
+elTime t = do
+  let s = formatZettelDate t
+  -- cf. https://developer.mozilla.org/en-US/docs/Web/HTML/Element/time#Attributes
+  elAttr "time" ("datetime" =: s) $ text s
+
+semanticIcon :: DomBuilder t m => Text -> m ()
+semanticIcon name = elClass "i" (name <> " icon") blank
+
+elLinkGoogleFonts :: DomBuilder t m => [Text] -> m ()
+elLinkGoogleFonts fs =
+  let fsEncoded = T.intercalate "|" $ T.replace " " "+" <$> fs
+      fsUrl = "https://fonts.googleapis.com/css?family=" <> fsEncoded <> "&display=swap"
+   in elAttr "link" ("rel" =: "stylesheet" <> "href" =: fsUrl) blank
diff --git a/src/lib/Neuron/Web/Widget/AutoScroll.hs b/src/lib/Neuron/Web/Widget/AutoScroll.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Web/Widget/AutoScroll.hs
@@ -0,0 +1,28 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Web.Widget.AutoScroll where
+
+import Data.Tagged
+import Reflex.Dom.Core hiding ((&))
+import Relude
+
+type AutoScroll = Tagged "autoScroll" (Maybe Text)
+
+-- | Hidden element to scroll to.
+-- cf. https://stackoverflow.com/a/49968820/55246
+marker :: DomBuilder t m => Text -> Int -> m ()
+marker elemId offsetPx = do
+  let style = "position: absolute; top: " <> show offsetPx <> "px; left: 0"
+  elAttr "div" ("id" =: elemId <> "style" =: style) blank
+
+-- | Place this at the bottom of the page, to have it auto scroll to the marker.
+--
+-- FIXME: This may not scroll sufficiently if the images in the zettel haven't
+-- loaded (thus the browser doesn't known the final height yet.)
+script :: DomBuilder t m => Text -> m ()
+script markerId = do
+  let s =
+        "if (window.scrollY == 0) { document.getElementById(\"" <> markerId <> "\").scrollIntoView({behavior: \"smooth\", block: \"start\"}); }"
+  el "script" $ text s
diff --git a/src/lib/Neuron/Web/Widget/InvertedTree.hs b/src/lib/Neuron/Web/Widget/InvertedTree.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Web/Widget/InvertedTree.hs
@@ -0,0 +1,144 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Web.Widget.InvertedTree
+  ( renderInvertedHeadlessTree,
+    style,
+  )
+where
+
+import Clay hiding (id, ms, not, object, reverse, s, style, type_)
+import qualified Clay as C
+import Data.List (maximum)
+import Data.Tree
+import Reflex.Dom.Core hiding ((&))
+import Relude hiding ((&))
+
+style :: Css
+style = pureCssTreeDiagram
+
+renderInvertedHeadlessTree ::
+  (DomBuilder t m, Ord a) =>
+  -- Element ID
+  Text ->
+  -- Element Class
+  Text ->
+  [Tree a] ->
+  (a -> m ()) ->
+  m ()
+renderInvertedHeadlessTree elemId elemCls tree w = do
+  let attrs =
+        "class" =: ("flipped tree " <> elemCls)
+          <> "id" =: elemId
+          <> "style" =: "transform-origin: 50%"
+  elAttr "nav" attrs $ do
+    elClass "ul" "root" $ do
+      -- Headless tree will still need a head element (li).
+      el "li" $ do
+        el "ul" $ do
+          renderInvertedForest w tree
+
+renderInvertedForest ::
+  (DomBuilder t m, Ord a) =>
+  (a -> m ()) ->
+  [Tree a] ->
+  m ()
+renderInvertedForest w trees = do
+  forM_ (sortForest trees) $ \(Node x subtrees) ->
+    el "li" $ do
+      divClass "forest-link" $
+        w x
+      when (length subtrees > 0) $ do
+        el "ul" $ renderInvertedForest w subtrees
+  where
+    -- Sort trees so that trees containing the most recent zettel (by ID) come first.
+    sortForest = reverse . sortOn maximum
+
+-- https://codepen.io/philippkuehn/pen/QbrOaN
+pureCssTreeDiagram :: Css
+pureCssTreeDiagram = do
+  let cellBorderWidth = px 2
+      flipTree = False
+      rotateDeg = deg 180
+  ".tree.flipped" ? do
+    C.transform $ C.rotate rotateDeg
+  ".tree" ? do
+    C.overflow auto
+    when flipTree $ do
+      C.transform $ C.rotate rotateDeg
+    -- Clay does not support this; doing it inline in div style.
+    -- C.transformOrigin $ pct 50
+    "ul.root" ? do
+      -- Make the tree attach to zettel segment
+      C.paddingTop $ px 0
+      C.marginTop $ px 0
+    "ul" ? do
+      C.position relative
+      C.padding (em 1) 0 0 0
+      C.whiteSpace nowrap
+      sym2 C.margin (px 0) auto
+      C.textAlign center
+      C.after & do
+        C.content $ stringContent ""
+        C.display C.displayTable
+        C.clear both
+      C.lastChild & do
+        C.paddingBottom $ em 0.1
+    "li" ? do
+      C.display C.inlineBlock
+      C.verticalAlign C.vAlignTop
+      C.textAlign C.center
+      C.listStyleType none
+      C.position relative
+      C.padding (em 1) (em 0.5) (em 0) (em 0.5)
+      forM_ [C.before, C.after] $ \sel -> sel & do
+        C.content $ stringContent ""
+        C.position absolute
+        C.top $ px 0
+        C.right $ pct 50
+        C.borderTop solid cellBorderWidth "#ccc"
+        C.width $ pct 50
+        C.height $ em 1.2
+      C.after & do
+        C.right auto
+        C.left $ pct 50
+        C.borderLeft solid cellBorderWidth "#ccc"
+      C.onlyChild & do
+        C.paddingTop $ em 0
+        forM_ [C.after, C.before] $ \sel -> sel & do
+          C.display none
+      C.firstChild & do
+        C.before & do
+          C.borderStyle none
+          C.borderWidth $ px 0
+        C.after & do
+          C.borderRadius (px 5) 0 0 0
+      C.lastChild & do
+        C.after & do
+          C.borderStyle none
+          C.borderWidth $ px 0
+        C.before & do
+          C.borderRight solid cellBorderWidth "#ccc"
+          C.borderRadius 0 (px 5) 0 0
+    "ul ul::before" ? do
+      C.content $ stringContent ""
+      C.position absolute
+      C.top $ px 0
+      C.left $ pct 50
+      C.borderLeft solid cellBorderWidth "#ccc"
+      C.width $ px 0
+      C.height $ em 1.2
+    "li" ? do
+      "div.forest-link" ? do
+        border solid cellBorderWidth "#ccc"
+        sym2 C.padding (em 0.2) (em 0.3)
+        C.textDecoration none
+        C.display inlineBlock
+        sym C.borderRadius (px 5)
+        C.color "#333"
+        C.position relative
+        C.top cellBorderWidth
+        when flipTree $ do
+          C.transform $ C.rotate rotateDeg
+  ".tree.flipped li div.forest-link" ? do
+    C.transform $ C.rotate rotateDeg
diff --git a/src/lib/Neuron/Web/ZIndex.hs b/src/lib/Neuron/Web/ZIndex.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Web/ZIndex.hs
@@ -0,0 +1,162 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Web.ZIndex
+  ( renderZIndex,
+    buildZIndex,
+    ZIndex (..),
+    style,
+  )
+where
+
+import Clay ((?), Css, em)
+import qualified Clay as C
+import Data.Foldable (maximum)
+import qualified Data.Map.Strict as Map
+import Data.TagTree (mkTagPattern)
+import Data.Tree
+import qualified Neuron.Web.Query.View as QueryView
+import Neuron.Web.Route
+import qualified Neuron.Web.Theme as Theme
+import Neuron.Zettelkasten.Connection
+import Neuron.Zettelkasten.Graph (ZettelGraph)
+import qualified Neuron.Zettelkasten.Graph as G
+import Neuron.Zettelkasten.ID
+import Neuron.Zettelkasten.Query (zettelsByTag)
+import Neuron.Zettelkasten.Query.Error (showQueryError)
+import Neuron.Zettelkasten.Zettel
+import Reflex.Dom.Core hiding ((&))
+import Relude hiding ((&))
+
+-- | The value needed to render the z-index
+--
+-- All heavy graph computations are decoupled from rendering, producing this
+-- value, that is in turn used for instant rendering.
+data ZIndex = ZIndex
+  { -- | Clusters on the folgezettel graph.
+    zIndexClusters :: [Forest (Zettel, [Zettel])],
+    -- | All zettel errors
+    zIndexErrors :: Map ZettelID ZettelError,
+    zIndexStats :: Stats,
+    zPinned :: [Zettel]
+  }
+
+data Stats = Stats
+  { statsZettelCount :: Int,
+    statsZettelConnectionCount :: Int
+  }
+  deriving (Eq, Show)
+
+buildZIndex :: ZettelGraph -> Map ZettelID ZettelError -> ZIndex
+buildZIndex graph errors =
+  let clusters = G.categoryClusters graph
+      clusters' :: [Forest (Zettel, [Zettel])] =
+        flip fmap clusters $ \(zs :: [Tree Zettel]) ->
+          G.backlinksMulti Folgezettel zs graph
+      stats = Stats (length $ G.getZettels graph) (G.connectionCount graph)
+      pinnedZettels = zettelsByTag (G.getZettels graph) [mkTagPattern "pinned"]
+   in ZIndex (fmap sortCluster clusters') errors stats pinnedZettels
+  where
+    -- TODO: Either optimize or get rid of this (or normalize the sorting somehow)
+    sortCluster fs =
+      sortZettelForest $ flip fmap fs $ \Node {..} ->
+        Node rootLabel $ sortZettelForest subForest
+    -- Sort zettel trees so that trees containing the most recent zettel (by ID) come first.
+    sortZettelForest = reverse . sortOn maximum
+
+renderZIndex ::
+  DomBuilder t m =>
+  Theme.Theme ->
+  ZIndex ->
+  NeuronWebT t m ()
+renderZIndex (Theme.semanticColor -> themeColor) ZIndex {..} = do
+  elClass "h1" "header" $ text "Zettel Index"
+  renderErrors zIndexErrors
+  divClass "z-index" $ do
+    el "p" $ do
+      text $
+        "The zettelkasten has "
+          <> countNounBe "zettel" "zettels" (statsZettelCount zIndexStats)
+          <> " and "
+          <> countNounBe "link" "links" (statsZettelConnectionCount zIndexStats)
+      text $ ". It has " <> countNounBe "cluster" "clusters" (length zIndexClusters) <> " in its folgezettel graph. "
+      text "Each cluster's "
+      elAttr "a" ("href" =: "https://neuron.zettel.page/2017401.html") $ text "folgezettel heterarchy"
+      text " is rendered as a forest."
+    forM_ (nonEmpty zPinned) $ \zs ->
+      divClass "ui message pinned raised segment" $ do
+        el "ul" $ forM_ zs $ \z ->
+          el "li" $ QueryView.renderZettelLink Nothing def z
+    forM_ zIndexClusters $ \forest ->
+      divClass ("ui " <> themeColor <> " segment") $ do
+        el "ul" $ renderForest forest
+  where
+    countNounBe noun nounPlural = \case
+      1 -> "1 " <> noun
+      n -> show n <> " " <> nounPlural
+
+renderErrors :: DomBuilder t m => Map ZettelID ZettelError -> NeuronWebT t m ()
+renderErrors errors = do
+  let severity = \case
+        ZettelError_ParseError _ -> "negative"
+        ZettelError_QueryErrors _ -> "warning"
+        ZettelError_AmbiguousFiles _ -> "negative"
+      errorMessageHeader zid = \case
+        ZettelError_ParseError _ -> do
+          text "Zettel "
+          QueryView.renderZettelLinkIDOnly zid
+          text " failed to parse"
+        ZettelError_QueryErrors _ -> do
+          text "Zettel "
+          QueryView.renderZettelLinkIDOnly zid
+          text " has malformed queries"
+        ZettelError_AmbiguousFiles _ -> do
+          text $
+            "More than one file define the same zettel ID ("
+              <> zettelIDText zid
+              <> "):"
+  forM_ (Map.toList errors) $ \(zid, zError) ->
+    divClass ("ui tiny message " <> severity zError) $ do
+      divClass "header" $ errorMessageHeader zid zError
+      el "p" $ do
+        case zError of
+          ZettelError_ParseError parseError ->
+            el "pre" $ text $ show parseError
+          ZettelError_QueryErrors queryErrors ->
+            el "ol" $ do
+              forM_ queryErrors $ \qe ->
+                el "li" $ el "pre" $ text $ showQueryError qe
+          ZettelError_AmbiguousFiles filePaths ->
+            el "ul" $ do
+              forM_ filePaths $ \fp ->
+                el "li" $ el "tt" $ text $ toText fp
+
+renderForest ::
+  DomBuilder t m =>
+  [Tree (Zettel, [Zettel])] ->
+  NeuronWebT t m ()
+renderForest trees = do
+  forM_ trees $ \(Node (zettel, uplinks) subtrees) ->
+    el "li" $ do
+      QueryView.renderZettelLink Nothing def zettel
+      when (length uplinks >= 2) $ do
+        elClass "span" "uplinks" $ do
+          forM_ uplinks $ \z2 -> do
+            el "small" $
+              elAttr "i" ("class" =: "linkify icon" <> "title" =: zettelTitle z2) blank
+      unless (null subtrees) $ do
+        el "ul" $ renderForest subtrees
+
+style :: Css
+style = do
+  "div.z-index" ? do
+    C.ul ? do
+      C.listStyleType C.square
+      C.paddingLeft $ em 1.5
+    ".uplinks" ? do
+      C.marginLeft $ em 0.3
diff --git a/src/lib/Neuron/Web/Zettel/CSS.hs b/src/lib/Neuron/Web/Zettel/CSS.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Web/Zettel/CSS.hs
@@ -0,0 +1,126 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Web.Zettel.CSS
+  ( zettelCss,
+  )
+where
+
+import Clay hiding (id, ms, not, object, reverse, s, style, type_)
+import qualified Clay as C
+import qualified Clay.Media as CM
+import qualified Neuron.Web.Theme as Theme
+import Neuron.Web.Widget.InvertedTree as IT
+import Relude hiding ((&))
+
+zettelCss :: Css
+zettelCss = do
+  Theme.themeCss
+  zettelCommonCss
+  C.queryOnly CM.screen [CM.maxWidth $ px 768] $ do
+    "div#zettel-container" ? do
+      -- Fix too big of margin on mobile.
+      C.important $ do
+        C.marginLeft $ em 0.4
+        C.marginRight $ em 0.4
+  "div.zettel-view" ? do
+    -- This list styling applies both to zettel content, and the rest of the
+    -- view (eg: connections pane)
+    C.ul ? do
+      C.paddingLeft $ em 1.5
+      C.listStyleType C.square
+      C.li ? do
+        mempty -- C.paddingBottom $ em 1
+    zettelContentCss
+  IT.style
+  ".ui.label.zettel-tag a.tag-inner" ? do
+    C.color black
+    "a" ? do
+      C.color black
+
+zettelContentCss :: Css
+zettelContentCss = do
+  ".zettel-content" ? do
+    -- All of these apply to the zettel content card only.
+    "div.date" ? do
+      C.textAlign C.center
+      C.color C.gray
+    C.h1 ? do
+      C.paddingTop $ em 0.2
+      C.paddingBottom $ em 0.2
+      C.textAlign C.center
+    C.h2 ? do
+      C.borderBottom C.solid (px 1) C.steelblue
+      C.marginBottom $ em 0.5
+    C.h3 ? do
+      C.margin (px 0) (px 0) (em 0.4) (px 0)
+    C.h4 ? do
+      C.opacity 0.8
+    "div#footnotes" ? do
+      C.marginTop $ em 4
+      C.borderTopStyle C.groove
+      C.borderTopWidth $ px 2
+      C.fontSize $ em 0.9
+    -- reflex-dom-pandoc footnote aside elements
+    -- (only used for footnotes defined inside footnotes)
+    "aside.footnote-inline" ? do
+      C.width $ pct 30
+      C.paddingLeft $ px 15
+      C.marginLeft $ px 15
+      C.float C.floatRight
+      C.backgroundColor C.lightgray
+    -- CSS library for users to use in their Pandoc attributes blocks
+    ".overflows" ? do
+      C.overflow auto
+    -- End of div.zettel-content
+    codeStyle
+    definitionListStyle
+    blockquoteStyle
+  ".zettel-content.raw" ? do
+    C.backgroundColor "#ddd"
+  where
+    definitionListStyle = do
+      C.dl ? do
+        C.dt ? do
+          C.fontWeight C.bold
+        C.dd ? do
+          mempty
+    codeStyle = do
+      C.code ? do
+        sym C.margin auto
+        C.fontSize $ pct 100
+      -- This pretty much selects inline code elements
+      "p code, li code, ol code" ? do
+        sym C.padding $ em 0.2
+        C.backgroundColor "#f8f8f8"
+      -- This selects block code elements
+      pre ? do
+        sym C.padding $ em 0.5
+        C.overflow auto
+        C.maxWidth $ pct 100
+      "div.pandoc-code" ? do
+        C.marginLeft auto
+        C.marginRight auto
+        pre ? do
+          C.backgroundColor "#f8f8f8"
+    -- https://css-tricks.com/snippets/css/simple-and-nice-blockquote-styling/
+    blockquoteStyle =
+      C.blockquote ? do
+        C.backgroundColor "#f9f9f9"
+        C.borderLeft C.solid (px 10) "#ccc"
+        sym2 C.margin (em 1.5) (px 0)
+        sym2 C.padding (em 0.5) (px 10)
+
+zettelCommonCss :: Css
+zettelCommonCss = do
+  "p" ? do
+    C.lineHeight $ pct 150
+  "img" ? do
+    C.maxWidth $ pct 100 -- Prevents large images from overflowing beyond zettel borders
+  ".deemphasized" ? do
+    fontSize $ em 0.85
+  ".deemphasized:hover" ? do
+    opacity 1
+  ".deemphasized:not(:hover)" ? do
+    opacity 0.7
+    "span.zettel-link a, div.item a" ? important (color gray)
diff --git a/src/lib/Neuron/Web/Zettel/View.hs b/src/lib/Neuron/Web/Zettel/View.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Web/Zettel/View.hs
@@ -0,0 +1,149 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Web.Zettel.View
+  ( renderZettel,
+    renderZettelContentCard,
+  )
+where
+
+import Data.TagTree
+import qualified Neuron.Web.Query.View as Q
+import Neuron.Web.Route
+import Neuron.Web.Widget
+import qualified Neuron.Web.Widget.AutoScroll as AS
+import qualified Neuron.Web.Widget.InvertedTree as IT
+import Neuron.Zettelkasten.Connection
+import Neuron.Zettelkasten.Graph (ZettelGraph)
+import qualified Neuron.Zettelkasten.Graph as G
+import Neuron.Zettelkasten.Query.Error (QueryError, showQueryError)
+import qualified Neuron.Zettelkasten.Query.Eval as Q
+import Neuron.Zettelkasten.Zettel
+import Reflex.Dom.Core hiding ((&))
+import Reflex.Dom.Pandoc
+import Relude hiding ((&))
+import Text.Pandoc.Definition (Pandoc)
+
+renderZettel ::
+  PandocBuilder t m =>
+  (ZettelGraph, ZettelC) ->
+  NeuronWebT t m ()
+renderZettel (graph, zc@(sansContent -> z)) = do
+  let upTree = G.backlinkForest Folgezettel z graph
+  unless (null upTree) $ do
+    IT.renderInvertedHeadlessTree "zettel-uptree" "deemphasized" upTree $ \z2 ->
+      Q.renderZettelLink (G.getConnection z z2 graph) def z2
+  -- Main content
+  elAttr "div" ("class" =: "ui text container" <> "id" =: "zettel-container" <> "style" =: "position: relative") $ do
+    whenStaticallyGenerated $ do
+      -- We use -24px (instead of -14px) here so as to not scroll all the way to
+      -- title, and as to leave some of the tree visible as "hint" to the user.
+      lift $ AS.marker "zettel-container-anchor" (-24)
+    divClass "zettel-view" $ do
+      renderZettelContentCard (graph, zc)
+      renderZettelBottomPane graph z
+  -- Because the tree above can be pretty large, we scroll past it
+  -- automatically when the page loads.
+  whenStaticallyGenerated $ do
+    unless (null upTree) $ do
+      AS.script "zettel-container-anchor"
+
+renderZettelContentCard ::
+  PandocBuilder t m =>
+  (ZettelGraph, ZettelC) ->
+  NeuronWebT t m ()
+renderZettelContentCard (graph, zc) =
+  case zc of
+    Right z ->
+      renderZettelContent (evalAndRenderZettelQuery graph) z
+    Left z -> do
+      renderZettelRawContent z
+
+renderZettelBottomPane :: DomBuilder t m => ZettelGraph -> Zettel -> NeuronWebT t m ()
+renderZettelBottomPane graph z@Zettel {..} = do
+  let cfBacklinks = nonEmpty $ fmap snd $ G.backlinks (== Just OrdinaryConnection) z graph
+      tags = nonEmpty zettelTags
+  when (isJust cfBacklinks || isJust tags)
+    $ elClass "nav" "ui bottom attached segment deemphasized"
+    $ do
+      divClass "ui two column grid" $ do
+        divClass "column" $ do
+          whenJust cfBacklinks $ \links -> do
+            elAttr "div" ("class" =: "ui header" <> "title" =: "Zettels that link here, but without branching") $
+              text "More backlinks"
+            el "ul" $ do
+              forM_ links $ \zl ->
+                el "li" $ Q.renderZettelLink Nothing def zl
+        whenJust tags $
+          divClass "column" . renderTags
+
+evalAndRenderZettelQuery ::
+  PandocBuilder t m =>
+  ZettelGraph ->
+  NeuronWebT t m [QueryError] ->
+  URILink ->
+  NeuronWebT t m [QueryError]
+evalAndRenderZettelQuery graph oldRender uriLink = do
+  case flip runReaderT (G.getZettels graph) (Q.runQueryURILink uriLink) of
+    Left e -> do
+      -- Error parsing or running the query.
+      fmap (e :) oldRender <* elInlineError e
+    Right Nothing -> do
+      -- This is not a query link; pass through.
+      oldRender
+    Right (Just res) -> do
+      Q.renderQueryResult res
+      pure mempty
+  where
+    elInlineError e =
+      elClass "span" "ui left pointing red basic label" $ do
+        text $ showQueryError e
+
+renderZettelContent ::
+  forall t m a.
+  (PandocBuilder t m, Monoid a) =>
+  (NeuronWebT t m a -> URILink -> NeuronWebT t m a) ->
+  ZettelT Pandoc ->
+  NeuronWebT t m ()
+renderZettelContent handleLink Zettel {..} = do
+  elClass "article" "ui raised attached segment zettel-content" $ do
+    unless zettelTitleInBody $ do
+      el "h1" $ text zettelTitle
+    void $ elPandoc (Config handleLink) zettelContent
+    whenJust zettelDay $ \day ->
+      elAttr "div" ("class" =: "date" <> "title" =: "Zettel creation date") $ do
+        text "Created on: "
+        elTime day
+
+renderZettelRawContent :: (DomBuilder t m) => ZettelT Text -> m ()
+renderZettelRawContent Zettel {..} = do
+  divClass "ui error message" $ do
+    elClass "h2" "header" $ text "Zettel failed to parse"
+    el "p" $ el "pre" $ text $ show zettelError
+  elClass "article" "ui raised attached segment zettel-content raw" $ do
+    el "pre" $ text $ zettelContent
+
+renderTags :: DomBuilder t m => NonEmpty Tag -> m ()
+renderTags tags = do
+  forM_ tags $ \t -> do
+    -- NOTE(ui): Ideally this should be at the top, not bottom. But putting it at
+    -- the top pushes the zettel content down, introducing unnecessary white
+    -- space below the title. So we put it at the bottom for now.
+    elAttr "span" ("class" =: "ui right ribbon label zettel-tag" <> "title" =: "Tag") $ do
+      elAttr
+        "a"
+        ( "href" =: (Q.tagUrl t)
+            <> "class" =: "tag-inner"
+            <> "title" =: ("See all zettels tagged '" <> unTag t <> "'")
+        )
+        $ text
+        $ unTag t
+    el "p" blank
diff --git a/src/lib/Neuron/Zettelkasten/Connection.hs b/src/lib/Neuron/Zettelkasten/Connection.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Zettelkasten/Connection.hs
@@ -0,0 +1,34 @@
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Zettelkasten.Connection where
+
+import Data.Aeson (FromJSON, ToJSON)
+import Data.Default
+import Relude hiding (show)
+import Text.Show
+
+-- | Represent the connection between zettels
+data Connection
+  = -- | A folgezettel points to a zettel that is conceptually a *part* of the
+    -- parent zettel.
+    Folgezettel
+  | -- | Any other ordinary connection (eg: "See also")
+    OrdinaryConnection
+  deriving (Eq, Ord, Enum, Bounded, Generic, ToJSON, FromJSON)
+
+instance Semigroup Connection where
+  -- A folgezettel link trumps all other kinds in that zettel.
+  Folgezettel <> _ = Folgezettel
+  _ <> Folgezettel = Folgezettel
+  OrdinaryConnection <> OrdinaryConnection = OrdinaryConnection
+
+instance Default Connection where
+  def = Folgezettel
+
+instance Show Connection where
+  show = \case
+    Folgezettel -> "folgezettel"
+    OrdinaryConnection -> "cf"
diff --git a/src/lib/Neuron/Zettelkasten/Graph.hs b/src/lib/Neuron/Zettelkasten/Graph.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Zettelkasten/Graph.hs
@@ -0,0 +1,108 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TupleSections #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Zettelkasten.Graph
+  ( -- * Graph type
+    ZettelGraph,
+
+    -- * Graph functions
+    getZettels,
+    getZettel,
+    getConnection,
+    topSort,
+    frontlinkForest,
+    backlinkForest,
+    backlinks,
+    backlinksMulti,
+    clusters,
+    categoryClusters,
+    connectionCount,
+  )
+where
+
+import qualified Algebra.Graph.Labelled.AdjacencyMap as LAM
+import Data.Default
+import Data.Foldable (maximum)
+import qualified Data.Graph.Labelled as G
+import Data.Tree
+import Neuron.Zettelkasten.Connection
+import Neuron.Zettelkasten.Graph.Type
+import Neuron.Zettelkasten.ID
+import Neuron.Zettelkasten.Zettel
+import Relude
+
+frontlinkForest :: Connection -> Zettel -> ZettelGraph -> Forest Zettel
+frontlinkForest conn z =
+  G.obviateRootUnlessForest z
+    . G.bfsForestFrom [z]
+    . G.induceOnEdge (== Just conn)
+
+backlinkForest :: Connection -> Zettel -> ZettelGraph -> Forest Zettel
+backlinkForest conn z =
+  G.obviateRootUnlessForest z
+    . G.bfsForestBackwards z
+    . G.induceOnEdge (== Just conn)
+
+backlinks ::
+  (Maybe Connection -> Bool) ->
+  Zettel ->
+  ZettelGraph ->
+  [(Connection, Zettel)]
+backlinks f z g =
+  mapMaybe (\(e, v) -> (,v) <$> e) $
+    G.preSetWithEdgeLabel f z g
+
+-- | Like backlinks but for multiple zettels. More performant than calling
+-- `backlinks` in a loop.
+backlinksMulti ::
+  (Functor f, Functor g) =>
+  Connection ->
+  f (g Zettel) ->
+  ZettelGraph ->
+  f (g (Zettel, [Zettel]))
+backlinksMulti conn zs g =
+  let f = G.preSetWithEdgeLabelMany (Just conn) g
+   in flip fmap zs $ \x ->
+        flip fmap x $ \y ->
+          (y, f y)
+
+categoryClusters :: ZettelGraph -> [Forest Zettel]
+categoryClusters (categoryGraph -> g) =
+  let cs :: [[Zettel]] = sortMothers $ clusters g
+   in flip fmap cs $ \zs -> G.bfsForestFrom zs g
+  where
+    -- Sort clusters with newer mother zettels appearing first.
+    sortMothers :: [NonEmpty Zettel] -> [[Zettel]]
+    sortMothers = sortOn (Down . maximum) . fmap (sortOn Down . toList)
+
+clusters :: ZettelGraph -> [NonEmpty Zettel]
+clusters g =
+  case (G.clusters $ categoryGraph g) of
+    [] ->
+      maybe [] pure $ nonEmpty $ G.getVertices g
+    cs ->
+      cs
+
+topSort :: ZettelGraph -> Either (NonEmpty Zettel) [Zettel]
+topSort = G.topSort . categoryGraph
+
+categoryGraph :: ZettelGraph -> ZettelGraph
+categoryGraph = G.induceOnEdge (== Just Folgezettel)
+
+getZettels :: ZettelGraph -> [Zettel]
+getZettels = G.getVertices
+
+getZettel :: ZettelID -> ZettelGraph -> Maybe Zettel
+getZettel = G.findVertex
+
+-- | If no connection exists, this returns Nothing.
+getConnection :: Zettel -> Zettel -> ZettelGraph -> Maybe Connection
+getConnection z1 z2 g = fmap (fromMaybe def) $ G.edgeLabel g z1 z2
+
+connectionCount :: ZettelGraph -> Int
+connectionCount = LAM.edgeCount . G.getGraph
diff --git a/src/lib/Neuron/Zettelkasten/Graph/Build.hs b/src/lib/Neuron/Zettelkasten/Graph/Build.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Zettelkasten/Graph/Build.hs
@@ -0,0 +1,72 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TupleSections #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Zettelkasten.Graph.Build
+  ( buildZettelkasten,
+  )
+where
+
+import Control.Monad.Writer (runWriterT)
+import qualified Data.Graph.Labelled as G
+import qualified Data.Map.Strict as Map
+import Neuron.Reader.Type
+import Neuron.Zettelkasten.Connection
+import Neuron.Zettelkasten.Graph.Type
+import Neuron.Zettelkasten.ID
+import Neuron.Zettelkasten.Query.Error (QueryError)
+import Neuron.Zettelkasten.Query.Eval (queryConnections)
+import Neuron.Zettelkasten.Zettel
+import Neuron.Zettelkasten.Zettel.Parser
+import Relude
+
+buildZettelkasten ::
+  [((ZettelFormat, ZettelReader), [(ZettelID, FilePath, Text)])] ->
+  ( ZettelGraph,
+    [ZettelC],
+    Map ZettelID ZettelError
+  )
+buildZettelkasten fs =
+  let zs = parseZettels fs
+      (g, queryErrors) = mkZettelGraph $ sansContent <$> zs
+      errors =
+        Map.unions
+          [ fmap ZettelError_ParseError
+              $ Map.fromList
+              $ lefts zs <&> (zettelID &&& zettelError),
+            fmap ZettelError_QueryErrors queryErrors
+          ]
+   in (g, zs, errors)
+
+-- | Build the Zettelkasten graph from a list of zettels
+--
+-- If there are any errors during parsing of queries (to determine connections),
+-- return them as well.
+mkZettelGraph ::
+  [Zettel] ->
+  ( ZettelGraph,
+    Map ZettelID (NonEmpty QueryError)
+  )
+mkZettelGraph zettels =
+  let res :: [(Zettel, ([(Maybe Connection, Zettel)], [QueryError]))] =
+        flip fmap zettels $ \z ->
+          (z, runQueryConnections zettels z)
+      g :: ZettelGraph = G.mkGraphFrom zettels $ flip concatMap res $ \(z1, fst -> conns) ->
+        edgeFromConnection z1 <$> conns
+      errors = Map.fromList $ flip mapMaybe res $ \(z, (nonEmpty . snd -> merrs)) ->
+        (zettelID z,) <$> merrs
+   in (g, errors)
+
+runQueryConnections :: [Zettel] -> Zettel -> ([(Maybe Connection, Zettel)], [QueryError])
+runQueryConnections zettels z =
+  flip runReader zettels $ do
+    runWriterT $ queryConnections z
+
+edgeFromConnection :: Zettel -> (Maybe Connection, Zettel) -> (Maybe Connection, Zettel, Zettel)
+edgeFromConnection z (c, z2) =
+  (connectionMonoid $ fromMaybe Folgezettel c, z, z2)
+  where
+    -- Our connection monoid will never be Nothing (mempty); see the note in
+    -- type `ZettelGraph`.
+    connectionMonoid = Just
diff --git a/src/lib/Neuron/Zettelkasten/Graph/Type.hs b/src/lib/Neuron/Zettelkasten/Graph/Type.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Zettelkasten/Graph/Type.hs
@@ -0,0 +1,23 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Zettelkasten.Graph.Type
+  ( -- * Graph type
+    ZettelGraph,
+  )
+where
+
+import Data.Graph.Labelled
+import Neuron.Zettelkasten.Connection
+import Neuron.Zettelkasten.Zettel
+import Relude
+
+-- | The Zettelkasten graph
+--
+-- Edges are labelled with `Connection`; Maybe is used to provide the
+-- `Algebra.Graph.Label.zero` value for the edge label, which is `Nothing` in
+-- our case, and is effectively the same as there not being an edge between
+-- those vertices.
+type ZettelGraph = LabelledGraph Zettel (Maybe Connection)
diff --git a/src/lib/Neuron/Zettelkasten/ID.hs b/src/lib/Neuron/Zettelkasten/ID.hs
--- a/src/lib/Neuron/Zettelkasten/ID.hs
+++ b/src/lib/Neuron/Zettelkasten/ID.hs
@@ -1,3 +1,5 @@
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveGeneric #-}
 {-# LANGUAGE GADTs #-}
 {-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
@@ -12,15 +14,17 @@
     parseZettelID,
     parseZettelID',
     idParser,
-    mkZettelID,
+    getZettelID,
     zettelIDSourceFileName,
     customIDParser,
   )
 where
 
-import Data.Aeson (ToJSON (toJSON))
+import Data.Aeson
+import Data.Aeson.Types (toJSONKeyText)
 import qualified Data.Text as T
 import Data.Time
+import Neuron.Reader.Type (ZettelFormat, zettelFormatToExtension)
 import Relude
 import System.FilePath
 import qualified Text.Megaparsec as M
@@ -34,12 +38,28 @@
     ZettelDateID Day Int
   | -- | Arbitrary alphanumeric ID.
     ZettelCustomID Text
-  deriving (Eq, Show, Ord)
+  deriving (Eq, Show, Ord, Generic)
 
 instance Show InvalidID where
   show (InvalidIDParseError s) =
     "Invalid Zettel ID: " <> toString s
 
+instance FromJSON ZettelID where
+  parseJSON x = do
+    s <- parseJSON x
+    case parseZettelID' s of
+      Left e -> fail $ show e
+      Right zid -> pure zid
+
+instance ToJSONKey ZettelID where
+  toJSONKey = toJSONKeyText zettelIDText
+
+instance FromJSONKey ZettelID where
+  fromJSONKey = FromJSONKeyTextParser $ \s ->
+    case parseZettelID' s of
+      Right v -> pure v
+      Left e -> fail $ show e
+
 instance ToJSON ZettelID where
   toJSON = toJSON . zettelIDText
 
@@ -62,15 +82,15 @@
         . T.replace "Sat" "6"
         . T.replace "Sun" "7"
 
-zettelIDSourceFileName :: ZettelID -> FilePath
-zettelIDSourceFileName zid = toString $ zettelIDText zid <> ".md"
+zettelIDSourceFileName :: ZettelID -> ZettelFormat -> FilePath
+zettelIDSourceFileName zid fmt = toString $ zettelIDText zid <> zettelFormatToExtension fmt
 
 ---------
 -- Parser
 ---------
 
 data InvalidID = InvalidIDParseError Text
-  deriving (Eq)
+  deriving (Eq, Generic, ToJSON, FromJSON)
 
 parseZettelID :: HasCallStack => Text -> ZettelID
 parseZettelID =
@@ -107,8 +127,8 @@
 customIDParser = do
   fmap toText $ M.some $ M.alphaNumChar <|> M.char '_' <|> M.char '-'
 
--- | Extract ZettelID from the zettel's filename or path.
-mkZettelID :: HasCallStack => FilePath -> ZettelID
-mkZettelID fp =
+-- | Parse the ZettelID if the given filepath is a zettel.
+getZettelID :: FilePath -> Maybe ZettelID
+getZettelID fp =
   let (name, _) = splitExtension $ takeFileName fp
-   in parseZettelID $ toText name
+   in rightToMaybe $ parseZettelID' $ toText name
diff --git a/src/lib/Neuron/Zettelkasten/Query.hs b/src/lib/Neuron/Zettelkasten/Query.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Zettelkasten/Query.hs
@@ -0,0 +1,128 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TupleSections #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Queries to the Zettel store
+module Neuron.Zettelkasten.Query
+  ( runZettelQuery,
+    runGraphQuery,
+    zettelQueryResultJson,
+    graphQueryResultJson,
+    zettelsByTag,
+  )
+where
+
+import Control.Monad.Except
+import Data.Aeson
+import qualified Data.Map.Strict as Map
+import Data.TagTree (Tag, TagPattern, tagMatch, tagMatchAny, tagTree)
+import Data.Tree (Tree (..))
+import Neuron.Zettelkasten.Graph (backlinks, getZettel)
+import Neuron.Zettelkasten.Graph.Type
+import Neuron.Zettelkasten.ID
+import Neuron.Zettelkasten.Query.Error (QueryResultError (..))
+import Neuron.Zettelkasten.Query.Graph
+import Neuron.Zettelkasten.Zettel
+import Relude
+
+runZettelQuery :: [Zettel] -> ZettelQuery r -> Either QueryResultError r
+runZettelQuery zs = \case
+  ZettelQuery_ZettelByID zid _ ->
+    case find ((== zid) . zettelID) zs of
+      Nothing ->
+        Left $ QueryResultError_NoSuchZettel zid
+      Just z ->
+        Right z
+  ZettelQuery_ZettelsByTag pats _mconn _mview ->
+    Right $ zettelsByTag zs pats
+  ZettelQuery_Tags [] ->
+    Right allTags
+  ZettelQuery_Tags pats ->
+    Right $ Map.filterWithKey (const . tagMatchAny pats) allTags
+  where
+    allTags :: Map.Map Tag Natural
+    allTags =
+      Map.fromListWith (+) $
+        concatMap (\Zettel {..} -> (,1) <$> zettelTags) zs
+
+zettelsByTag :: [Zettel] -> [TagPattern] -> [Zettel]
+zettelsByTag zs pats =
+  sortZettelsReverseChronological $ flip filter zs $ \Zettel {..} ->
+    and $ flip fmap pats $ \pat ->
+      any (tagMatch pat) zettelTags
+
+runGraphQuery :: ZettelGraph -> GraphQuery r -> Either QueryResultError r
+runGraphQuery g = \case
+  GraphQuery_Id -> Right g
+  GraphQuery_BacklinksOf conn zid ->
+    case getZettel zid g of
+      Nothing ->
+        Left $ QueryResultError_NoSuchZettel zid
+      Just z ->
+        Right $ backlinks (maybe isJust (const (== conn)) conn) z g
+
+zettelQueryResultJson ::
+  forall r.
+  (ToJSON (ZettelQuery r)) =>
+  ZettelQuery r ->
+  Either QueryResultError r ->
+  -- Zettels that cannot be parsed by neuron
+  Map ZettelID ZettelError ->
+  Value
+zettelQueryResultJson q er skippedZettels =
+  toJSON $
+    object
+      [ "query" .= toJSON q,
+        either
+          (\e -> "error" .= toJSON e)
+          (\r -> "result" .= toJSON (resultJson r))
+          er,
+        "skipped" .= skippedZettels
+      ]
+  where
+    resultJson :: r -> Value
+    resultJson r = case q of
+      ZettelQuery_ZettelByID _ _mconn ->
+        toJSON r
+      ZettelQuery_ZettelsByTag _ _mconn _mview ->
+        toJSON r
+      ZettelQuery_Tags _ ->
+        toJSON $ fmap treeToJson . tagTree $ r
+    treeToJson (Node (tag, count) children) =
+      object
+        [ "name" .= tag,
+          "count" .= count,
+          "children" .= fmap treeToJson children
+        ]
+
+graphQueryResultJson ::
+  forall r.
+  (ToJSON (GraphQuery r)) =>
+  GraphQuery r ->
+  Either QueryResultError r ->
+  -- Zettels that cannot be parsed by neuron (and as such are excluded from the graph)
+  Map ZettelID ZettelError ->
+  Value
+graphQueryResultJson q er skippedZettels =
+  toJSON $
+    object
+      [ "query" .= toJSON q,
+        either
+          (\e -> "error" .= toJSON e)
+          (\r -> "result" .= resultJson r)
+          er,
+        "skipped" .= skippedZettels
+      ]
+  where
+    resultJson :: r -> Value
+    resultJson r = case q of
+      GraphQuery_Id ->
+        toJSON r
+      GraphQuery_BacklinksOf _ _ ->
+        toJSON r
diff --git a/src/lib/Neuron/Zettelkasten/Query/Error.hs b/src/lib/Neuron/Zettelkasten/Query/Error.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Zettelkasten/Query/Error.hs
@@ -0,0 +1,50 @@
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Zettelkasten.Query.Error where
+
+import Data.Aeson
+import Neuron.Orphans ()
+import Neuron.Zettelkasten.ID (InvalidID, ZettelID, zettelIDText)
+import Relude
+import Text.URI (URI)
+import qualified Text.URI as URI
+
+type QueryError = Either QueryParseError QueryResultError
+
+data QueryParseError
+  = QueryParseError_InvalidID URI InvalidID
+  | QueryParseError_UnsupportedHost URI
+  deriving (Eq, Show, Generic, ToJSON, FromJSON)
+
+-- | This error is only thrown when *using* (eg: in HTML) the query results.
+data QueryResultError = QueryResultError_NoSuchZettel ZettelID
+  deriving (Eq, Show, Generic, ToJSON, FromJSON)
+
+queryParseErrorUri :: QueryParseError -> URI
+queryParseErrorUri = \case
+  QueryParseError_InvalidID uri _ -> uri
+  QueryParseError_UnsupportedHost uri -> uri
+
+showQueryError :: QueryError -> Text
+showQueryError = \case
+  Left qe ->
+    showQueryParseError qe
+  Right re ->
+    showQueryResultError re
+
+showQueryParseError :: QueryParseError -> Text
+showQueryParseError qe =
+  let uri = URI.render (queryParseErrorUri qe)
+   in uri <> ": " <> case qe of
+        QueryParseError_UnsupportedHost _uri ->
+          "unsupported host"
+        QueryParseError_InvalidID _uri e'' ->
+          "invalidID: " <> show e''
+
+showQueryResultError :: QueryResultError -> Text
+showQueryResultError (QueryResultError_NoSuchZettel zid) =
+  "links to non-existant zettel: " <> zettelIDText zid
diff --git a/src/lib/Neuron/Zettelkasten/Query/Eval.hs b/src/lib/Neuron/Zettelkasten/Query/Eval.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Zettelkasten/Query/Eval.hs
@@ -0,0 +1,94 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TupleSections #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Zettelkasten.Query.Eval
+  ( runQueryURILink,
+    queryConnections,
+  )
+where
+
+import Control.Monad.Except
+import Control.Monad.Writer
+import Data.Dependent.Sum
+import Data.Some
+import Neuron.Zettelkasten.Connection
+import Neuron.Zettelkasten.Query (runZettelQuery)
+import Neuron.Zettelkasten.Query.Error
+import Neuron.Zettelkasten.Query.Parser (queryFromURILink)
+import Neuron.Zettelkasten.Zettel
+import Reflex.Dom.Pandoc.URILink (URILink)
+import Relude
+
+-- | Evaluate the given query link and return its results.
+--
+-- Return Nothing if the link is not a query.
+--
+-- We need the full list of zettels, for running the query against.
+runQueryURILink ::
+  ( MonadError QueryError m,
+    MonadReader [Zettel] m
+  ) =>
+  URILink ->
+  m (Maybe (DSum ZettelQuery Identity))
+runQueryURILink ul = do
+  mq <- liftEither $ first Left $ queryFromURILink ul
+  flip traverse mq $ \q ->
+    either (throwError . Right) pure =<< runExceptT (runSomeZettelQuery q)
+
+-- Query connections in the given zettel
+--
+-- Tell all errors; query parse errors (as already stored in `Zettel`) as well
+-- query result errors.
+queryConnections ::
+  ( -- Errors are written aside, accumulating valid connections.
+    MonadWriter [QueryError] m,
+    -- Running queries requires the zettels list.
+    MonadReader [Zettel] m
+  ) =>
+  Zettel ->
+  m [(Maybe Connection, Zettel)]
+queryConnections Zettel {..} = do
+  -- Report any query parse errors
+  case zettelError of
+    Right queryParseErrors ->
+      tell $ Left <$> queryParseErrors
+    Left _ ->
+      pure ()
+  fmap concat $ forM zettelQueries $ \someQ ->
+    runExceptT (runSomeZettelQuery someQ) >>= \case
+      Left e -> do
+        tell [Right e]
+        pure mempty
+      Right res ->
+        pure $ getConnections res
+  where
+    getConnections :: DSum ZettelQuery Identity -> [(Maybe Connection, Zettel)]
+    getConnections = \case
+      ZettelQuery_ZettelByID _ mconn :=> Identity res ->
+        [(mconn, res)]
+      ZettelQuery_ZettelsByTag _ mconn _mview :=> Identity res ->
+        (mconn,) <$> res
+      ZettelQuery_Tags _ :=> _ ->
+        mempty
+
+runSomeZettelQuery ::
+  ( MonadError QueryResultError m,
+    MonadReader [Zettel] m
+  ) =>
+  Some ZettelQuery ->
+  m (DSum ZettelQuery Identity)
+runSomeZettelQuery someQ =
+  withSome someQ $ \q -> do
+    zs <- ask
+    case runZettelQuery zs q of
+      Left e ->
+        throwError e
+      Right res ->
+        pure $ q :=> Identity res
diff --git a/src/lib/Neuron/Zettelkasten/Query/Graph.hs b/src/lib/Neuron/Zettelkasten/Query/Graph.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Zettelkasten/Query/Graph.hs
@@ -0,0 +1,41 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE StandaloneDeriving #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE UndecidableInstances #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Zettelkasten.Query.Graph where
+
+import Data.Aeson
+import Data.Aeson.GADT.TH
+import Data.Dependent.Sum.Orphans ()
+import Data.GADT.Compare.TH
+import Data.GADT.Show.TH
+import Neuron.Zettelkasten.Connection
+import Neuron.Zettelkasten.Graph.Type
+import Neuron.Zettelkasten.ID
+import Neuron.Zettelkasten.Zettel
+import Relude
+
+-- | Like `GraphQuery` but focused on the relationship between zettels.
+data GraphQuery r where
+  -- | Query the entire graph.
+  GraphQuery_Id :: GraphQuery ZettelGraph
+  -- | Query backlinks.
+  GraphQuery_BacklinksOf ::
+    Maybe Connection ->
+    ZettelID ->
+    GraphQuery [(Connection, Zettel)]
+
+deriveJSONGADT ''GraphQuery
+
+deriveGEq ''GraphQuery
+
+deriveGShow ''GraphQuery
+
+deriving instance Show (GraphQuery ZettelGraph)
+
+deriving instance Eq (GraphQuery ZettelGraph)
diff --git a/src/lib/Neuron/Zettelkasten/Query/Parser.hs b/src/lib/Neuron/Zettelkasten/Query/Parser.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Zettelkasten/Query/Parser.hs
@@ -0,0 +1,126 @@
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE NamedFieldPuns #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Zettelkasten.Query.Parser where
+
+import Control.Monad.Except
+import Data.Some
+import Data.TagTree (mkTagPattern)
+import Neuron.Zettelkasten.Connection
+import Neuron.Zettelkasten.ID
+import Neuron.Zettelkasten.Query.Error
+import Neuron.Zettelkasten.Query.Theme
+import Neuron.Zettelkasten.Zettel (ZettelQuery (..))
+import Reflex.Dom.Pandoc (URILink (..))
+import Relude
+import qualified Text.URI as URI
+import Text.URI.QQ (queryKey)
+import Text.URI.Util (getQueryParam, hasQueryFlag)
+
+-- | Parse a query from the given URI.
+--
+-- This function is used only in the CLI. For handling links in a Markdown file,
+-- your want `queryFromMarkdownLink` which allows specifying the link text as
+-- well.
+queryFromURI :: MonadError QueryParseError m => URI.URI -> m (Maybe (Some ZettelQuery))
+queryFromURI uri = do
+  -- We are setting markdownLinkText to the URI to support the new short links
+  queryFromURILink $ URILink (URI.render uri) uri
+
+queryFromURILink :: MonadError QueryParseError m => URILink -> m (Maybe (Some ZettelQuery))
+queryFromURILink (URILink linkText uri) =
+  case fmap URI.unRText (URI.uriScheme uri) of
+    Just proto | not angleBracketLink && proto `elem` ["z", "zcf"] -> do
+      zid <- liftEither $ first (QueryParseError_InvalidID uri) $ parseZettelID' linkText
+      let mconn = if proto == "zcf" then Just OrdinaryConnection else Nothing
+      pure $ Just $ Some $ ZettelQuery_ZettelByID zid mconn
+    Just proto | proto `elem` ["zquery", "zcfquery"] ->
+      case uriHost uri of
+        Right "search" -> do
+          let mconn = if proto == "zcfquery" then Just OrdinaryConnection else Nothing
+          pure $ Just $ Some $
+            ZettelQuery_ZettelsByTag (tagPatterns "tag") mconn queryView
+        Right "tags" ->
+          pure $ Just $ Some $ ZettelQuery_Tags (tagPatterns "filter")
+        _ ->
+          throwError $ QueryParseError_UnsupportedHost uri
+    _ -> pure $ do
+      -- Modern links:
+      -- First, we expect that this is inside <..> (so same link text as link)
+      guard angleBracketLink
+      -- Then, non-relevant parts of the URI should be empty
+      guard $ URI.uriFragment uri == Nothing
+      let mconn =
+            if hasQueryFlag [queryKey|cf|] uri
+              then Just OrdinaryConnection
+              else Nothing
+          -- Found "z:" without a trailing slash
+          noSlash = URI.uriAuthority uri == Left False
+          -- Found "z:/" instead of "z:"
+          hasSlash = URI.uriAuthority uri == Left True
+      case fmap URI.unRText (URI.uriScheme uri) of
+        Just "z" -> do
+          fmap snd (URI.uriPath uri) >>= \case
+            (URI.unRText -> path) :| []
+              | hasSlash -> do
+                zid <- rightToMaybe $ parseZettelID' path
+                pure $ Some $ ZettelQuery_ZettelByID zid mconn
+            (URI.unRText -> "zettel") :| [URI.unRText -> path]
+              | noSlash -> do
+                zid <- rightToMaybe $ parseZettelID' path
+                pure $ Some $ ZettelQuery_ZettelByID zid mconn
+            (URI.unRText -> "zettels") :| []
+              | noSlash -> do
+                pure $ Some $ ZettelQuery_ZettelsByTag (tagPatterns "tag") mconn queryView
+            (URI.unRText -> "tags") :| []
+              | noSlash -> do
+                pure $ Some $ ZettelQuery_Tags (tagPatterns "filter")
+            _ -> Nothing
+        Just _ -> do
+          Nothing
+        Nothing -> do
+          -- Alias to short links
+          fmap snd (URI.uriPath uri) >>= \case
+            (URI.unRText -> path) :| [] -> do
+              zid <- rightToMaybe $ parseZettelID' path
+              pure $ Some $ ZettelQuery_ZettelByID zid mconn
+            _ ->
+              -- Multiple path elements, not supported
+              Nothing
+  where
+    angleBracketLink = URI.render uri == linkText
+    tagPatterns k =
+      mkTagPattern <$> getParamValues k uri
+    queryView =
+      let isTimeline =
+            -- linkTheme=withDate is legacy format; timeline is current standard.
+            getQueryParam [queryKey|linkTheme|] uri == Just "withDate"
+              || hasQueryFlag [queryKey|timeline|] uri
+          isGrouped = hasQueryFlag [queryKey|grouped|] uri
+          linkView =
+            if isTimeline
+              then LinkView_ShowDate
+              else
+                if hasQueryFlag [queryKey|showid|] uri
+                  then LinkView_ShowID
+                  else LinkView_Default
+       in ZettelsView linkView isGrouped
+    getParamValues k u =
+      flip mapMaybe (URI.uriQuery u) $ \case
+        URI.QueryParam (URI.unRText -> key) (URI.unRText -> val) ->
+          if key == k
+            then Just val
+            else Nothing
+        _ -> Nothing
+    uriHost u =
+      fmap (URI.unRText . URI.authHost) (URI.uriAuthority u)
diff --git a/src/lib/Neuron/Zettelkasten/Query/Theme.hs b/src/lib/Neuron/Zettelkasten/Query/Theme.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Zettelkasten/Query/Theme.hs
@@ -0,0 +1,32 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Zettelkasten.Query.Theme where
+
+import Data.Aeson (FromJSON, ToJSON)
+import Data.Default
+import Relude
+
+data ZettelsView = ZettelsView
+  { zettelsViewLinkView :: LinkView,
+    zettelsViewGroupByTag :: Bool
+  }
+  deriving (Eq, Show, Ord, Generic, ToJSON, FromJSON)
+
+type ZettelView = LinkView
+
+data LinkView
+  = LinkView_Default
+  | LinkView_ShowDate
+  | LinkView_ShowID
+  deriving (Eq, Show, Ord, Generic, ToJSON, FromJSON)
+
+instance Default LinkView where
+  def = LinkView_Default
+
+instance Default ZettelsView where
+  def = ZettelsView def False
diff --git a/src/lib/Neuron/Zettelkasten/Zettel.hs b/src/lib/Neuron/Zettelkasten/Zettel.hs
--- a/src/lib/Neuron/Zettelkasten/Zettel.hs
+++ b/src/lib/Neuron/Zettelkasten/Zettel.hs
@@ -1,35 +1,108 @@
+{-# LANGUAGE DeriveAnyClass #-}
+{-# LANGUAGE DeriveFunctor #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE FlexibleContexts #-}
 {-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RankNTypes #-}
 {-# LANGUAGE RecordWildCards #-}
 {-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE StandaloneDeriving #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE TupleSections #-}
 {-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE UndecidableInstances #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 
 module Neuron.Zettelkasten.Zettel where
 
 import Data.Aeson
+import Data.Aeson.GADT.TH
+import Data.Dependent.Sum.Orphans ()
+import Data.GADT.Compare.TH
+import Data.GADT.Show.TH
 import Data.Graph.Labelled (Vertex (..))
+import Data.Some
 import Data.TagTree (Tag)
+import Data.TagTree (TagPattern (..))
 import Data.Time.Calendar
+import Neuron.Reader.Type
+import Neuron.Zettelkasten.Connection
 import Neuron.Zettelkasten.ID
-import qualified Neuron.Zettelkasten.Zettel.Meta as Meta
+import Neuron.Zettelkasten.Query.Error
+import Neuron.Zettelkasten.Query.Theme
 import Relude hiding (show)
-import Text.MMark (MMark)
-import qualified Text.MMark as MMark
-import qualified Text.Megaparsec as M
+import Text.Pandoc.Definition (Pandoc (..))
 import Text.Show (Show (show))
 
+-- | ZettelQuery queries individual zettels.
+--
+-- It does not care about the relationship *between* those zettels; for that use `GraphQuery`.
+data ZettelQuery r where
+  ZettelQuery_ZettelByID :: ZettelID -> Maybe Connection -> ZettelQuery Zettel
+  ZettelQuery_ZettelsByTag :: [TagPattern] -> Maybe Connection -> ZettelsView -> ZettelQuery [Zettel]
+  ZettelQuery_Tags :: [TagPattern] -> ZettelQuery (Map Tag Natural)
+
+-- | A zettel note
+--
+-- The metadata could have been inferred from the content.
 data ZettelT content = Zettel
   { zettelID :: ZettelID,
+    zettelFormat :: ZettelFormat,
+    -- Relative path to this zettel in the zettelkasten directory
+    zettelPath :: FilePath,
     zettelTitle :: Text,
+    zettelTitleInBody :: Bool,
     zettelTags :: [Tag],
     zettelDay :: Maybe Day,
+    zettelQueries :: [Some ZettelQuery],
+    zettelError :: ContentError content,
     zettelContent :: content
   }
+  deriving (Generic)
 
-type Zettel = ZettelT MMark
+newtype MetadataOnly = MetadataOnly ()
+  deriving (Generic, ToJSON, FromJSON)
 
+type family ContentError c where
+-- The list of queries that failed to parse.
+  ContentError Pandoc = [QueryParseError]
+-- When a zettel fails to parse, we use its raw text along with its parse error.
+  ContentError Text = ZettelParseError
+-- When working with zettel sans content, we gather both kinds of errors (above)
+  ContentError MetadataOnly = Either (ContentError Text) (ContentError Pandoc)
+
+-- | All possible errors in a zettel
+--
+-- NOTE: Unlike `ContentError MetadataOnly` this also includes QueryResultError
+-- (which can be determined only after *evaluating* the queries).
+data ZettelError
+  = ZettelError_ParseError ZettelParseError
+  | ZettelError_QueryErrors (NonEmpty QueryError)
+  | ZettelError_AmbiguousFiles (NonEmpty FilePath)
+  deriving (Eq, Show, Generic, ToJSON, FromJSON)
+
+-- | Zettel without its content
+type Zettel = ZettelT MetadataOnly
+
+-- | Zettel with its content (Pandoc or raw text)
+type ZettelC = Either (ZettelT Text) (ZettelT Pandoc)
+
+sansContent :: ZettelC -> Zettel
+sansContent = \case
+  Left z ->
+    z
+      { zettelError = Left $ zettelError z,
+        zettelContent = MetadataOnly ()
+      }
+  Right z ->
+    z
+      { zettelError = Right $ zettelError z,
+        zettelContent = MetadataOnly ()
+      }
+
 instance Eq (ZettelT c) where
   (==) = (==) `on` zettelID
 
@@ -47,31 +120,24 @@
 sortZettelsReverseChronological =
   sortOn (Down . zettelDay)
 
-zettelJson :: forall a c. KeyValue a => ZettelT c -> [a]
-zettelJson Zettel {..} =
-  [ "id" .= toJSON zettelID,
-    "title" .= zettelTitle,
-    "tags" .= zettelTags,
-    "day" .= zettelDay
-  ]
+deriveJSONGADT ''ZettelQuery
 
-mkZettelFromMarkdown ::
-  ZettelID ->
-  Text ->
-  ((Text, MMark) -> content) ->
-  Either Text (ZettelT content)
-mkZettelFromMarkdown zid s selectContent = do
-  doc <- parseMarkdown (toString $ zettelIDText zid) s
-  let meta = Meta.getMeta doc
-      title = maybe "Missing title" Meta.title meta
-      tags = fromMaybe [] $ Meta.tags =<< meta
-      day = case zid of
-        -- We ignore the "data" meta field on legacy Date IDs, which encode the
-        -- creation date in the ID.
-        ZettelDateID v _ -> Just v
-        ZettelCustomID _ -> Meta.date =<< meta
-  pure $
-    Zettel zid title tags day (selectContent (s, doc))
-  where
-    parseMarkdown k =
-      first (toText . M.errorBundlePretty) . MMark.parse k
+deriveGEq ''ZettelQuery
+
+deriveGShow ''ZettelQuery
+
+deriving instance Show (ZettelQuery (Maybe Zettel))
+
+deriving instance Show (ZettelQuery [Zettel])
+
+deriving instance Show (ZettelQuery (Map Tag Natural))
+
+deriving instance Eq (ZettelQuery (Maybe Zettel))
+
+deriving instance Eq (ZettelQuery [Zettel])
+
+deriving instance Eq (ZettelQuery (Map Tag Natural))
+
+deriving instance ToJSON Zettel
+
+deriving instance FromJSON Zettel
diff --git a/src/lib/Neuron/Zettelkasten/Zettel/Meta.hs b/src/lib/Neuron/Zettelkasten/Zettel/Meta.hs
--- a/src/lib/Neuron/Zettelkasten/Zettel/Meta.hs
+++ b/src/lib/Neuron/Zettelkasten/Zettel/Meta.hs
@@ -1,32 +1,66 @@
-{-# LANGUAGE DeriveAnyClass #-}
 {-# LANGUAGE DeriveGeneric #-}
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TypeApplications #-}
 {-# LANGUAGE NoImplicitPrelude #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
 
 module Neuron.Zettelkasten.Zettel.Meta
   ( Meta (..),
-    getMeta,
+    formatZettelDate,
+    parseZettelDate,
   )
 where
 
-import Data.Aeson
 import Data.TagTree (Tag)
-import Data.Time.Calendar
+import Data.Time
+import Data.YAML
 import Relude
-import Text.MMark (MMark, projectYaml)
 
 -- | YAML metadata in a zettel markdown file
 data Meta = Meta
-  { title :: Text,
+  { title :: Maybe Text,
     tags :: Maybe [Tag],
     -- | Creation day
     date :: Maybe Day
   }
-  deriving (Eq, Show, Generic, FromJSON)
+  deriving (Eq, Show, Generic)
 
-getMeta :: MMark -> Maybe Meta
-getMeta src = do
-  val <- projectYaml src
-  case fromJSON val of
-    Error e -> error $ "JSON error: " <> toText e
-    Success v -> pure v
+instance FromYAML Meta where
+  parseYAML =
+    withMap "Meta" $ \m ->
+      Meta
+        <$> m .:? "title"
+        -- "keywords" is an alias for "tags"
+        <*> (liftA2 (<|>) (m .:? "tags") (m .:? "keywords"))
+        <*> m .:? "date"
+
+-- NOTE: Not using this instance because it generates "tags: null" when tags is
+-- Nothing.
+-- instance ToYAML Meta where
+--   toYAML Meta {..} =
+--     mapping
+--       [ "title" .= title,
+--         "tags" .= tags,
+--         "date" .= date
+--       ]
+
+instance FromYAML Day where
+  parseYAML =
+    parseZettelDate <=< parseYAML @Text
+
+instance ToYAML Day where
+  toYAML =
+    toYAML . formatZettelDate
+
+-- | The format in which we decode and encode zettel dates.
+zettelDateFormat :: String
+zettelDateFormat = "%Y-%m-%d"
+
+formatZettelDate :: Day -> Text
+formatZettelDate =
+  toText . formatTime defaultTimeLocale zettelDateFormat
+
+parseZettelDate :: MonadFail m => Text -> m Day
+parseZettelDate =
+  parseTimeM False defaultTimeLocale zettelDateFormat . toString
diff --git a/src/lib/Neuron/Zettelkasten/Zettel/Parser.hs b/src/lib/Neuron/Zettelkasten/Zettel/Parser.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Neuron/Zettelkasten/Zettel/Parser.hs
@@ -0,0 +1,68 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TupleSections #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Zettelkasten.Zettel.Parser where
+
+import Control.Monad.Writer
+import Data.Some
+import qualified Data.Text as T
+import Neuron.Reader.Type
+import Neuron.Zettelkasten.ID
+import Neuron.Zettelkasten.Query.Error
+import Neuron.Zettelkasten.Query.Parser (queryFromURILink)
+import Neuron.Zettelkasten.Zettel
+import qualified Neuron.Zettelkasten.Zettel.Meta as Meta
+import Reflex.Dom.Pandoc.URILink (queryURILinks)
+import Relude
+import Text.Pandoc.Definition (Pandoc)
+import Text.Pandoc.Util
+
+parseZettel ::
+  ZettelFormat ->
+  ZettelReader ->
+  FilePath ->
+  ZettelID ->
+  Text ->
+  ZettelC
+parseZettel format zreader fn zid s = do
+  case zreader fn s of
+    Left parseErr ->
+      Left $ Zettel zid format fn "Unknown" False [] Nothing [] parseErr s
+    Right (meta, doc) ->
+      let (title, titleInBody) = case Meta.title =<< meta of
+            Just tit -> (tit, False)
+            Nothing -> fromMaybe ("Untitled", False) $ do
+              ((,True) . plainify . snd <$> getH1 doc)
+                <|> ((,False) . takeInitial . plainify <$> getFirstParagraphText doc)
+          tags = fromMaybe [] $ Meta.tags =<< meta
+          day = case zid of
+            -- We ignore the "data" meta field on legacy Date IDs, which encode the
+            -- creation date in the ID.
+            ZettelDateID v _ -> Just v
+            ZettelCustomID _ -> Meta.date =<< meta
+          (queries, errors) = runWriter $ extractQueries doc
+       in Right $ Zettel zid format fn title titleInBody tags day queries errors doc
+  where
+    -- Extract all (valid) queries from the Pandoc document
+    extractQueries :: MonadWriter [QueryParseError] m => Pandoc -> m [Some ZettelQuery]
+    extractQueries doc =
+      fmap catMaybes $ forM (queryURILinks doc) $ \ul ->
+        case queryFromURILink ul of
+          Left e -> do
+            tell [e]
+            pure Nothing
+          Right v ->
+            pure v
+    takeInitial =
+      (<> " ...") . T.take 18
+
+-- | Like `parseZettel` but operates on multiple files.
+parseZettels ::
+  [((ZettelFormat, ZettelReader), [(ZettelID, FilePath, Text)])] ->
+  [ZettelC]
+parseZettels filesPerFormat =
+  flip concatMap filesPerFormat $ \((format, zreader), files) ->
+    flip fmap files $ \(zid, path, s) ->
+      parseZettel format zreader path zid s
diff --git a/src/lib/Text/Pandoc/Util.hs b/src/lib/Text/Pandoc/Util.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Text/Pandoc/Util.hs
@@ -0,0 +1,50 @@
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Text.Pandoc.Util
+  ( getFirstParagraphText,
+    getH1,
+    plainify,
+  )
+where
+
+import Relude
+import qualified Text.Pandoc.Builder as B
+import Text.Pandoc.Definition (Pandoc (..))
+import qualified Text.Pandoc.Walk as W
+
+getFirstParagraphText :: Pandoc -> Maybe [B.Inline]
+getFirstParagraphText = listToMaybe . W.query go
+  where
+    go :: B.Block -> [[B.Inline]]
+    go = \case
+      B.Para inlines ->
+        [inlines]
+      _ ->
+        []
+
+getH1 :: Pandoc -> Maybe (B.Attr, [B.Inline])
+getH1 = listToMaybe . W.query go
+  where
+    go :: B.Block -> [(B.Attr, [B.Inline])]
+    go = \case
+      B.Header 1 attr inlines ->
+        [(attr, inlines)]
+      _ ->
+        []
+
+-- | Convert Pandoc AST inlines to raw text.
+plainify :: [B.Inline] -> Text
+plainify = W.query $ \case
+  B.Str x -> x
+  B.Code _attr x -> x
+  B.Space -> " "
+  B.SoftBreak -> " "
+  B.LineBreak -> " "
+  B.RawInline _fmt s -> s
+  B.Math _mathTyp s -> s
+  -- Ignore the rest of AST nodes, as they are recursively defined in terms of
+  -- `Inline` which `W.query` will traverse again.
+  _ -> ""
diff --git a/src/lib/Text/URI/Util.hs b/src/lib/Text/URI/Util.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Text/URI/Util.hs
@@ -0,0 +1,30 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Text.URI.Util where
+
+import Relude
+import qualified Text.URI as URI
+
+getQueryParam :: URI.RText 'URI.QueryKey -> URI.URI -> Maybe Text
+getQueryParam k uri =
+  listToMaybe $ catMaybes $ flip fmap (URI.uriQuery uri) $ \case
+    URI.QueryFlag _ -> Nothing
+    URI.QueryParam key (URI.unRText -> val) ->
+      if key == k
+        then Just val
+        else Nothing
+
+hasQueryFlag :: URI.RText 'URI.QueryKey -> URI.URI -> Bool
+hasQueryFlag k uri =
+  fromMaybe False $ listToMaybe $ catMaybes $ flip fmap (URI.uriQuery uri) $ \case
+    URI.QueryFlag key ->
+      if key == k
+        then Just True
+        else Nothing
+    _ -> Nothing
diff --git a/test/Neuron/VersionSpec.hs b/test/Neuron/VersionSpec.hs
--- a/test/Neuron/VersionSpec.hs
+++ b/test/Neuron/VersionSpec.hs
@@ -23,15 +23,15 @@
         isLesserOrEqual = shouldNotSatisfy
     it "simple versions" $ do
       -- If the user requires 0.4, and we are "older than" than that, fail (aka. isGreater)
-      "0.5" `isGreater` olderThan
-      "0.4" `isLesserOrEqual` olderThan -- This is current version
-      "0.2" `isLesserOrEqual` olderThan
+      "0.7" `isGreater` olderThan
+      "0.6" `isLesserOrEqual` olderThan -- This is current version
+      "0.4" `isLesserOrEqual` olderThan
     it "full versions" $ do
-      "0.5.1.2" `isGreater` olderThan
-      "0.4.3" `isGreater` olderThan
-      "0.4.1.8" `isGreater` olderThan
-      "0.4.0.0" `isLesserOrEqual` olderThan -- This is current version
+      "0.7.1.2" `isGreater` olderThan
+      "0.6.12" `isGreater` olderThan
+      "0.6.11.8" `isGreater` olderThan
+      "0.6.0.0" `isLesserOrEqual` olderThan -- This is current version
       "0.3.1.0" `isLesserOrEqual` olderThan
     it "within same major version" $ do
-      "0.4.1.8" `isGreater` olderThan
-      "0.4.0.0" `isLesserOrEqual` olderThan -- This is current version
+      "0.6.11.8" `isGreater` olderThan
+      "0.6.0.0" `isLesserOrEqual` olderThan -- This is current version
diff --git a/test/Neuron/Zettelkasten/IDSpec.hs b/test/Neuron/Zettelkasten/IDSpec.hs
--- a/test/Neuron/Zettelkasten/IDSpec.hs
+++ b/test/Neuron/Zettelkasten/IDSpec.hs
@@ -8,6 +8,7 @@
 
 import qualified Data.Aeson as Aeson
 import Data.Time.Calendar
+import qualified Neuron.Reader.Type as Z
 import qualified Neuron.Zettelkasten.ID as Z
 import Relude
 import Test.Hspec
@@ -21,15 +22,15 @@
       it "parses a zettel ID" $ do
         Z.parseZettelID "2011401" `shouldBe` zid
       it "parses a zettel ID from zettel filename" $ do
-        Z.mkZettelID "2011401.md" `shouldBe` zid
-        Z.zettelIDSourceFileName zid `shouldBe` "2011401.md"
+        Z.getZettelID "2011401.md" `shouldBe` Just zid
+        Z.zettelIDSourceFileName zid Z.ZettelFormat_Markdown `shouldBe` "2011401.md"
     context "custom id parsing" $ do
       let zid = Z.ZettelCustomID "20abcde"
       it "parses a custom zettel ID" $ do
         Z.parseZettelID' "20abcde" `shouldBe` Right zid
       it "parses a custom zettel ID from zettel filename" $ do
-        Z.mkZettelID "20abcde.md" `shouldBe` zid
-        Z.zettelIDSourceFileName zid `shouldBe` "20abcde.md"
+        Z.getZettelID "20abcde.md" `shouldBe` Just zid
+        Z.zettelIDSourceFileName zid Z.ZettelFormat_Markdown `shouldBe` "20abcde.md"
       let deceptiveZid = Z.ZettelCustomID "2136537e"
       it "parses a custom zettel ID that looks like date ID" $ do
         Z.parseZettelID' "2136537e" `shouldBe` Right deceptiveZid
diff --git a/test/Neuron/Zettelkasten/Query/ParserSpec.hs b/test/Neuron/Zettelkasten/Query/ParserSpec.hs
--- a/test/Neuron/Zettelkasten/Query/ParserSpec.hs
+++ b/test/Neuron/Zettelkasten/Query/ParserSpec.hs
@@ -12,12 +12,12 @@
 import Data.TagTree
 import Neuron.Zettelkasten.Connection
 import Neuron.Zettelkasten.ID
-import Neuron.Zettelkasten.Query
 import Neuron.Zettelkasten.Query.Parser
 import Neuron.Zettelkasten.Query.Theme
+import Neuron.Zettelkasten.Zettel
+import Reflex.Dom.Pandoc.URILink
 import Relude
 import Test.Hspec
-import Text.MMark.MarkdownLink
 import Text.URI
 
 spec :: Spec
@@ -31,75 +31,81 @@
     for_ [("zquery", Nothing), ("zcfquery", Just OrdinaryConnection)] $ \(scheme, mconn) -> do
       context scheme $ do
         let zettelsByTag pat mview =
-              Right $ Just $ Some $ Query_ZettelsByTag (fmap mkTagPattern pat) mconn mview
+              Right $ Just $ Some $ ZettelQuery_ZettelsByTag (fmap mkTagPattern pat) mconn mview
             withScheme s = toText scheme <> s
-            legacyLink l = mkMarkdownLink "." l
+            legacyLink l = mkURILink "." l
         it "Parse all zettels URI" $ do
-          queryFromMarkdownLink (legacyLink $ withScheme "://search")
+          queryFromURILink (legacyLink $ withScheme "://search")
             `shouldBe` zettelsByTag [] def
         it "Parse single tag" $
-          queryFromMarkdownLink (legacyLink $ withScheme "://search?tag=foo")
+          queryFromURILink (legacyLink $ withScheme "://search?tag=foo")
             `shouldBe` zettelsByTag ["foo"] def
         it "Parse hierarchical tag" $ do
-          queryFromMarkdownLink (legacyLink $ withScheme "://search?tag=foo/bar")
+          queryFromURILink (legacyLink $ withScheme "://search?tag=foo/bar")
             `shouldBe` zettelsByTag ["foo/bar"] def
         it "Parse tag pattern" $ do
-          queryFromMarkdownLink (legacyLink $ withScheme "://search?tag=foo/**/bar/*/baz")
+          queryFromURILink (legacyLink $ withScheme "://search?tag=foo/**/bar/*/baz")
             `shouldBe` zettelsByTag ["foo/**/bar/*/baz"] def
         it "Parse multiple tags" $
-          queryFromMarkdownLink (legacyLink $ withScheme "://search?tag=foo&tag=bar")
+          queryFromURILink (legacyLink $ withScheme "://search?tag=foo&tag=bar")
             `shouldBe` zettelsByTag ["foo", "bar"] def
         it "Handles ?grouped" $ do
-          queryFromMarkdownLink (legacyLink $ withScheme "://search?grouped")
+          queryFromURILink (legacyLink $ withScheme "://search?grouped")
             `shouldBe` zettelsByTag [] (ZettelsView def True)
         it "Handles ?linkTheme=withDate" $ do
-          queryFromMarkdownLink (legacyLink $ withScheme "://search?linkTheme=withDate")
-            `shouldBe` zettelsByTag [] (ZettelsView (LinkView True) False)
+          queryFromURILink (legacyLink $ withScheme "://search?linkTheme=withDate")
+            `shouldBe` zettelsByTag [] (ZettelsView (LinkView_ShowDate) False)
   describe "Parse zettels by ID URI" $ do
     let zid = parseZettelID "1234567"
     it "parses z:/" $
-      queryFromMarkdownLink (mkMarkdownLink "1234567" "z:/")
-        `shouldBe` Right (Just $ Some $ Query_ZettelByID zid Nothing)
+      queryFromURILink (mkURILink "1234567" "z:/")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID zid Nothing)
     it "parses z:/ ignoring annotation" $
-      queryFromMarkdownLink (mkMarkdownLink "1234567" "z://foo-bar")
-        `shouldBe` Right (Just $ Some $ Query_ZettelByID zid Nothing)
+      queryFromURILink (mkURILink "1234567" "z://foo-bar")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID zid Nothing)
     it "parses zcf:/" $
-      queryFromMarkdownLink (mkMarkdownLink "1234567" "zcf:/")
-        `shouldBe` Right (Just $ Some $ Query_ZettelByID zid (Just OrdinaryConnection))
+      queryFromURILink (mkURILink "1234567" "zcf:/")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID zid (Just OrdinaryConnection))
   describe "Parse tags URI" $ do
     it "parses zquery://tags" $
-      queryFromMarkdownLink (mkMarkdownLink "." "zquery://tags?filter=foo/**")
-        `shouldBe` Right (Just $ Some $ Query_Tags [mkTagPattern "foo/**"])
+      queryFromURILink (mkURILink "." "zquery://tags?filter=foo/**")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_Tags [mkTagPattern "foo/**"])
 
 shortLinks :: Spec
 shortLinks = do
   describe "short links" $ do
-    let shortLink s = mkMarkdownLink s s
+    let shortLink s = mkURILink s s
     it "parses date ID" $ do
-      queryFromMarkdownLink (shortLink "1234567")
-        `shouldBe` Right (Just $ Some $ Query_ZettelByID (parseZettelID "1234567") Nothing)
+      queryFromURILink (shortLink "1234567")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (parseZettelID "1234567") Nothing)
     it "parses custom/hash ID" $ do
-      queryFromMarkdownLink (shortLink "foo-bar")
-        `shouldBe` Right (Just $ Some $ Query_ZettelByID (parseZettelID "foo-bar") Nothing)
+      queryFromURILink (shortLink "foo-bar")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (parseZettelID "foo-bar") Nothing)
     it "even with ?cf" $ do
-      queryFromMarkdownLink (shortLink "foo-bar?cf")
-        `shouldBe` Right (Just $ Some $ Query_ZettelByID (parseZettelID "foo-bar") (Just OrdinaryConnection))
+      queryFromURILink (shortLink "foo-bar?cf")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (parseZettelID "foo-bar") (Just OrdinaryConnection))
+    it "parses prefixed short link" $ do
+      queryFromURILink (shortLink "z:/foo-bar")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (parseZettelID "foo-bar") Nothing)
+    it "resolves ambiguity using absolute URI" $ do
+      queryFromURILink (shortLink "z:/tags")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (parseZettelID "tags") Nothing)
     it "z:zettels" $ do
-      queryFromMarkdownLink (shortLink "z:zettels")
-        `shouldBe` Right (Just $ Some $ Query_ZettelsByTag [] Nothing def)
+      queryFromURILink (shortLink "z:zettels")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelsByTag [] Nothing def)
     it "z:zettels?tag=foo" $ do
-      queryFromMarkdownLink (shortLink "z:zettels?tag=foo")
-        `shouldBe` Right (Just $ Some $ Query_ZettelsByTag [mkTagPattern "foo"] Nothing def)
+      queryFromURILink (shortLink "z:zettels?tag=foo")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelsByTag [mkTagPattern "foo"] Nothing def)
     it "z:zettels?cf" $ do
-      queryFromMarkdownLink (shortLink "z:zettels?cf")
-        `shouldBe` Right (Just $ Some $ Query_ZettelsByTag [] (Just OrdinaryConnection) def)
+      queryFromURILink (shortLink "z:zettels?cf")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelsByTag [] (Just OrdinaryConnection) def)
     it "z:tags" $ do
-      queryFromMarkdownLink (shortLink "z:tags")
-        `shouldBe` Right (Just $ Some $ Query_Tags [])
+      queryFromURILink (shortLink "z:tags")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_Tags [])
     it "z:tags?filter=foo" $ do
-      queryFromMarkdownLink (shortLink "z:tags?filter=foo")
-        `shouldBe` Right (Just $ Some $ Query_Tags [mkTagPattern "foo"])
+      queryFromURILink (shortLink "z:tags?filter=foo")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_Tags [mkTagPattern "foo"])
 
-mkMarkdownLink :: Text -> Text -> MarkdownLink
-mkMarkdownLink s l =
-  MarkdownLink s $ either (error . toText . displayException) id $ mkURI l
+mkURILink :: Text -> Text -> URILink
+mkURILink s l =
+  URILink s $ either (error . toText . displayException) id $ mkURI l
diff --git a/test/Neuron/Zettelkasten/ZettelSpec.hs b/test/Neuron/Zettelkasten/ZettelSpec.hs
--- a/test/Neuron/Zettelkasten/ZettelSpec.hs
+++ b/test/Neuron/Zettelkasten/ZettelSpec.hs
@@ -7,37 +7,37 @@
   )
 where
 
-import Data.Aeson
 import Data.TagTree
 import Data.Time.Calendar
+import Neuron.Reader.Markdown
+import Neuron.Reader.Type
 import Neuron.Zettelkasten.ID
 import Neuron.Zettelkasten.Zettel
+import Neuron.Zettelkasten.Zettel.Meta (Meta)
 import Relude
-import Rib.Parser.MMark (parsePure)
 import Test.Hspec
 
 spec :: Spec
 spec = do
+  let noQueries = mempty -- TODO: test queries
+      noError = Right mempty
+      noContent = MetadataOnly ()
   describe "sortZettelsReverseChronological" $ do
     let mkDay = fromGregorian 2020 3
-        dummyContent = either error id $ parsePure "<spec>" "Dummy"
+        (_ :: Maybe Meta, _dummyContent) = either (error . show) id $ parseMarkdown "<spec>" "Dummy"
         mkZettel day idx =
-          Zettel (ZettelDateID (mkDay day) idx) "Some title" [Tag "science", Tag "journal/class"] (Just $ mkDay day) dummyContent
+          Zettel
+            (ZettelDateID (mkDay day) idx)
+            ZettelFormat_Markdown
+            "<spec>.md"
+            "Some title"
+            False
+            [Tag "science", Tag "journal/class"]
+            (Just $ mkDay day)
+            noQueries
+            noError
+            noContent
     it "sorts correctly" $ do
       let zs = [mkZettel 3 2, mkZettel 5 1]
       sortZettelsReverseChronological zs
         `shouldBe` [mkZettel 5 1, mkZettel 3 2]
-  describe "Zettel JSON" $ do
-    let day = fromGregorian 2020 3 19
-        zid = ZettelCustomID "Foo-Bar"
-        dummyContent = either error id $ parsePure "<spec>" "Dummy"
-        zettel = Zettel zid "Some title" [Tag "science", Tag "journal/class"] (Just day) dummyContent
-    it "Produces expected json" $ do
-      -- "{\"id\":\"2011401\",\"title\":\"Some title\",\"tags\":[\"science\"]}"
-      object (zettelJson zettel)
-        `shouldBe` object
-          [ "id" .= ("Foo-Bar" :: Text),
-            "title" .= ("Some title" :: Text),
-            "tags" .= (["science", "journal/class"] :: [Text]),
-            "day" .= day
-          ]
