diff --git a/neuron.cabal b/neuron.cabal
--- a/neuron.cabal
+++ b/neuron.cabal
@@ -1,7 +1,7 @@
 cabal-version: 2.4
 name: neuron
 -- This version must be in sync with what's in Default.dhall
-version: 0.6.0.0
+version: 1.0.0.0
 license: AGPL-3.0-only
 copyright: 2020 Sridhar Ratnakumar
 maintainer: srid@srid.ca
@@ -47,21 +47,21 @@
     containers,
     filepattern,
     megaparsec,
-    commonmark ^>= 0.1.0.0,
-    commonmark-extensions ^>= 0.1.0.0,
-    commonmark-pandoc ^>= 0.1.0.0,
-    pandoc-types,
+    commonmark ^>= 0.1.0.2,
+    commonmark-extensions ^>= 0.2.0.0,
+    commonmark-pandoc >= 0.1.0.0 && < 0.3,
+    pandoc-types >= 1.21,
     parsec,
     modern-uri,
     HsYAML,
     aeson-gadt-th,
     dependent-sum,
     dependent-sum-template,
-    dependent-sum-aeson-orphans,
+    dependent-sum-aeson-orphans >= 0.2.1.0,
     data-default,
     reflex,
     reflex-dom-core,
-    reflex-dom-pandoc >= 0.2.0.0,
+    reflex-dom-pandoc >= 0.6.0.0,
     clay,
     tagged
 
@@ -107,12 +107,10 @@
     Data.Graph.Labelled.Type
     Data.Graph.Labelled.Algorithm
     Data.Graph.Labelled.Build
+    Data.Time.DateMayTime
 
--- A trick to make ghcid reload if library dependencies change
--- https://haskell.zettel.page/2012605.html
 common app-common
   import: library-common
-  hs-source-dirs: src/app src/lib
   default-language: Haskell2010
   build-depends:
     base >=4.12 && < 4.14,
@@ -125,10 +123,11 @@
     file-embed,
     optparse-applicative,
     pandoc,
-    skylighting-core,
+    -- Use skylighting >= 0.9 as they are written in pure Haskell
+    skylighting-core >= 0.9,
     relude,
     iso8601-time,
-    rib >=0.12,
+    rib-core >=1.0,
     shake -any,
     time,
     text,
@@ -139,71 +138,93 @@
     which,
     unix,
     megaparsec >= 8.0,
-    dependent-sum,
+    dependent-sum >= 0.7,
     dependent-sum-template,
     data-default,
     uuid,
     shake,
     with-utf8,
     shower,
-    either
-  if (!flag(ghcid))
-    autogen-modules:
-      Paths_neuron
-    other-modules:
-      Data.Graph.Labelled
-      Data.Graph.Labelled.Algorithm
-      Data.Graph.Labelled.Build
-      Data.Graph.Labelled.Type
-      Data.PathTree
-      Data.Structured.Breadcrumb
-      Data.TagTree
-      Neuron.CLI
-      Neuron.CLI.New
-      Neuron.CLI.Rib
-      Neuron.CLI.Search
-      Neuron.CLI.Types
-      Neuron.Config
-      Neuron.Config.Alias
-      Neuron.Config.Orphans
-      Neuron.Version
-      Neuron.Web.Generate
-      Neuron.Web.Generate.Route
-      Neuron.Web.View
-      Neuron.Web.StructuredData
-      Neuron.Reader
-      Neuron.Reader.Org
-      Neuron.Zettelkasten.ID.Scheme
-      Paths_neuron
-      Text.Megaparsec.Simple
+    either,
+    uri-encode
 
+-- Everything below will have to be stripped away, if whoever uses this repo
+-- will want to avoid non-core dependencies. Cabal doesn't seem to provide a way
+-- to selectively dependent on a library without pulling in every stanza defined
+-- here.
+-- cf. https://www.srid.ca/2012605.html
+
+library neuron-app-lib
+  import: app-common
+  hs-source-dirs: src/app
+  exposed-modules:
+    Data.Structured.Breadcrumb
+    Data.Structured.OpenGraph
+    Data.Structured.OpenGraph.Render
+    Neuron.CLI
+    Neuron.CLI.New
+    Neuron.CLI.Open
+    Neuron.CLI.Rib
+    Neuron.CLI.Search
+    Neuron.CLI.Types
+    Neuron.Config
+    Neuron.Config.Alias
+    Neuron.Config.Orphans
+    Neuron.Version
+    Neuron.Web.Cache
+    Neuron.Web.HeadHtml
+    Neuron.Web.Generate
+    Neuron.Web.Generate.Route
+    Neuron.Web.View
+    Neuron.Web.StructuredData
+    Neuron.Reader
+    Neuron.Reader.Org
+    Neuron.Zettelkasten.ID.Scheme
+  autogen-modules:
+    Paths_neuron
+  other-modules:
+    Paths_neuron
+  -- A trick to make ghcid reload if library dependencies change
+  -- https://www.srid.ca/2012605.html
+  if flag(ghcid)
+    hs-source-dirs: src/lib
+  else
+    build-depends: neuron
+
 test-suite neuron-test
   import: app-common
   type: exitcode-stdio-1.0
   hs-source-dirs: test
   main-is: Spec.hs
   build-depends:
-    base >=4.12 && < 4.14,
     relude,
     hspec,
     QuickCheck,
     time
   default-language:    Haskell2010
-  if (!flag(ghcid))
+  if (flag(ghcid))
+    hs-source-dirs: src/lib src/app
+  else
+    build-depends: neuron, neuron-app-lib
     other-modules:
       Data.PathTreeSpec
       Data.TagTreeSpec
+      Data.Graph.Labelled.AlgorithmSpec
       Neuron.Config.AliasSpec
       Neuron.VersionSpec
       Neuron.Zettelkasten.ID.SchemeSpec
       Neuron.Zettelkasten.IDSpec
       Neuron.Zettelkasten.Query.ParserSpec
       Neuron.Zettelkasten.ZettelSpec
+      Neuron.Zettelkasten.Zettel.ParserSpec
 
--- 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
+  hs-source-dirs: src/exe
   main-is: Main.hs
   ghc-options: -threaded -rtsopts -with-rtsopts=-N
+  if (flag(ghcid))
+    hs-source-dirs: src/lib src/app
+  else
+    build-depends: neuron, neuron-app-lib
diff --git a/src-bash/neuron-search b/src-bash/neuron-search
--- a/src-bash/neuron-search
+++ b/src-bash/neuron-search
@@ -4,9 +4,10 @@
 NOTESDIR=${1}
 FILTERBY=${2}
 SEARCHFROMFIELD=${3}
-OPENCMD=$(envsubst -no-unset -no-empty <<<${4})
+EXTENSIONS=${4}
+OPENCMD=$(envsubst -no-unset -no-empty <<<${5})
 cd ${NOTESDIR}
-rg --no-heading --no-line-number --with-filename --sort path "${FILTERBY}" *.md \
+rg --no-heading --no-line-number --with-filename --sort path "${FILTERBY}" -g "${EXTENSIONS}" \
   | fzf --tac --no-sort -d ':' -n ${SEARCHFROMFIELD}.. \
     --preview 'bat --style=plain --color=always {1}' \
   | awk -F: "{printf \"${NOTESDIR}/%s\", \$1}" \
diff --git a/src/app/Data/Structured/OpenGraph.hs b/src/app/Data/Structured/OpenGraph.hs
new file mode 100644
--- /dev/null
+++ b/src/app/Data/Structured/OpenGraph.hs
@@ -0,0 +1,46 @@
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+-- | Meta tags for The Open Graph protocol: https://ogp.me/
+module Data.Structured.OpenGraph
+  ( OpenGraph (..),
+    OGType (..),
+    Article (..),
+  )
+where
+
+import Data.Time (UTCTime)
+import Relude
+import qualified Text.URI as URI
+
+-- The OpenGraph metadata
+--
+-- This type can be directly rendered to HTML using `toHTML`.
+data OpenGraph = OpenGraph
+  { _openGraph_title :: Text,
+    _openGraph_url :: Maybe URI.URI,
+    _openGraph_author :: Maybe Text,
+    _openGraph_description :: Maybe Text,
+    _openGraph_siteName :: Text,
+    _openGraph_type :: Maybe OGType,
+    _openGraph_image :: Maybe URI.URI
+  }
+  deriving (Eq, Show)
+
+-- TODO: Remaining ADT values & sub-fields
+data OGType
+  = OGType_Article Article
+  | OGType_Website
+  deriving (Eq, Show)
+
+-- TODO: _article_profile :: [Profile]
+data Article = Article
+  { _article_section :: Maybe Text,
+    _article_modifiedTime :: Maybe UTCTime,
+    _article_publishedTime :: Maybe UTCTime,
+    _article_expirationTime :: Maybe UTCTime,
+    _article_tag :: [Text]
+  }
+  deriving (Eq, Show)
diff --git a/src/app/Data/Structured/OpenGraph/Render.hs b/src/app/Data/Structured/OpenGraph/Render.hs
new file mode 100644
--- /dev/null
+++ b/src/app/Data/Structured/OpenGraph/Render.hs
@@ -0,0 +1,44 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Data.Structured.OpenGraph.Render where
+
+import Data.Structured.OpenGraph
+import Data.Time.ISO8601 (formatISO8601)
+import Reflex.Dom.Core
+import Relude
+import qualified Text.URI as URI
+
+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'
diff --git a/src/app/Main.hs b/src/app/Main.hs
deleted file mode 100644
--- a/src/app/Main.hs
+++ /dev/null
@@ -1,41 +0,0 @@
-{-# LANGUAGE DataKinds #-}
-{-# LANGUAGE GADTs #-}
-{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE ScopedTypeVariables #-}
-{-# LANGUAGE TypeApplications #-}
-{-# LANGUAGE NoImplicitPrelude #-}
-
-module Main where
-
-import Control.Monad.Reader
-import Development.Shake
-import Main.Utf8
-import Neuron.CLI (run)
-import Neuron.Config.Type (Config)
-import Neuron.Version (neuronVersion)
-import Neuron.Web.Generate (generateSite)
-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
-
-main :: IO ()
-main = withUtf8 $ run generateMainSite
-
-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
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
@@ -16,41 +16,39 @@
 import Data.Time
 import Development.Shake (Action)
 import Neuron.CLI.New (newZettelFile)
+import Neuron.CLI.Open (openLocallyGeneratedFile)
 import Neuron.CLI.Rib
 import Neuron.CLI.Search (interactiveSearch)
+import Neuron.CLI.Types (QueryCommand (..))
 import Neuron.Config (getConfig)
 import Neuron.Config.Type (Config)
 import qualified Neuron.Version as Version
+import qualified Neuron.Web.Cache as Cache
 import qualified Neuron.Web.Generate as Gen
 import qualified Neuron.Zettelkasten.Graph as G
 import qualified Neuron.Zettelkasten.Query as Q
 import Options.Applicative
 import Relude
-import qualified Rib
 import System.Directory
-import System.FilePath
-import System.Info (os)
-import System.Posix.Process
 
 run :: (Config -> Action ()) -> IO ()
 run act = do
-  cliParser <- commandParser <$> defaultNotesDir <*> today
+  defaultNotesDir <- getCurrentDirectory
+  cliParser <- commandParser defaultNotesDir <$> now
   app <-
     execParser $
       info
         (versionOption <*> cliParser <**> helper)
-        (fullDesc <> progDesc "Neuron, a Zettelkasten CLI <https://neuron.zettel.page/>")
+        (fullDesc <> progDesc "Neuron, future-proof Zettelkasten app <https://neuron.zettel.page/>")
   runWith act app
   where
     versionOption =
       infoOption
         (toString Version.neuronVersion)
         (long "version" <> help "Show version")
-    defaultNotesDir =
-      (</> "zettelkasten") <$> getHomeDirectory
-    today = do
+    now = do
       tz <- getCurrentTimeZone
-      localDay . utcToLocalTime tz <$> liftIO getCurrentTime
+      utcToLocalTime tz <$> liftIO getCurrentTime
 
 runWith :: (Config -> Action ()) -> App -> IO ()
 runWith act App {..} =
@@ -60,16 +58,16 @@
     New newCommand ->
       runRibOnceQuietly notesDir $ do
         newZettelFile newCommand =<< getConfig
-    Open ->
+    Open openCommand ->
       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 eSomeQ ->
+        openLocallyGeneratedFile openCommand
+    Query (QueryCommand {..}) ->
       runRibOnceQuietly notesDir $ do
-        (graph, _, errors) <- Gen.loadZettelkasten =<< getConfig
-        case eSomeQ of
+        (graph, errors) <-
+          if cached
+            then Cache.getCache
+            else Gen.loadZettelkastenGraph =<< getConfig
+        case query of
           Left someQ ->
             withSome someQ $ \q -> do
               let result = Q.runZettelQuery (G.getZettels graph) q
@@ -78,5 +76,6 @@
             withSome someQ $ \q -> do
               let result = Q.runGraphQuery graph q
               putLTextLn $ Aeson.encodeToLazyText $ Q.graphQueryResultJson q result errors
-    Search searchCmd ->
-      interactiveSearch notesDir searchCmd
+    Search searchCmd -> do
+      runRibOnceQuietly notesDir $ do
+        interactiveSearch notesDir searchCmd =<< getConfig
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,7 +15,7 @@
 import Data.Some
 import Data.Text (strip)
 import qualified Data.Text as T
-import Data.Time
+import Data.Time.DateMayTime (DateMayTime, formatDateMayTime)
 import Development.Shake (Action)
 import Neuron.CLI.Types
 import Neuron.Config.Type (Config (..), getZettelFormats)
@@ -24,10 +24,9 @@
 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 Rib.Shake (ribInputDir)
 import System.Directory (setCurrentDirectory)
 import System.FilePath
 import qualified System.Posix.Env as Env
@@ -49,14 +48,14 @@
   case mzid of
     Left e -> die $ show e
     Right zid -> do
-      notesDir <- Rib.ribInputDir
+      notesDir <- ribInputDir
       defaultFormat <- head <$> getZettelFormats config
       let zettelFormat = fromMaybe defaultFormat format
           zettelFile = zettelIDSourceFileName zid zettelFormat
       liftIO $ do
         fileAction :: FilePath -> FilePath -> IO () <-
           bool (pure showAction) mkEditActionFromEnv edit
-        writeFileText (notesDir </> zettelFile) $ defaultZettelContent zettelFormat day title
+        writeFileText (notesDir </> zettelFile) $ defaultZettelContent zettelFormat date
         fileAction notesDir zettelFile
   where
     mkEditActionFromEnv :: IO (FilePath -> FilePath -> IO ())
@@ -82,29 +81,22 @@
         Just (toString . strip . toText -> v) ->
           if null v then pure Nothing else pure (Just v)
 
--- TODO use configurable template files?
-defaultZettelContent :: ZettelFormat -> Day -> Maybe Text -> Text
-defaultZettelContent format day mtitle = case format of
+defaultZettelContent :: ZettelFormat -> DateMayTime -> Text
+defaultZettelContent format (formatDateMayTime -> date) = case format of
   ZettelFormat_Markdown ->
     T.intercalate
       "\n"
       [ "---",
         "date: " <> date,
         "---",
-        "",
-        "# " <> title,
-        "\n"
+        ""
       ]
   ZettelFormat_Org ->
     T.intercalate
       "\n"
-      [ "* " <> title,
+      [ "* " <> "Untitled", -- Leaving a title here, only because org seems to require one
         "    :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/Open.hs b/src/app/Neuron/CLI/Open.hs
new file mode 100644
--- /dev/null
+++ b/src/app/Neuron/CLI/Open.hs
@@ -0,0 +1,30 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE RecordWildCards #-}
+
+module Neuron.CLI.Open
+  ( openLocallyGeneratedFile,
+  )
+where
+
+import Data.Some
+import qualified Data.Text as T
+import Development.Shake (Action, doesFileExist)
+import Neuron.CLI.Types (OpenCommand (..))
+import Neuron.Web.Generate.Route ()
+import Relude
+import Rib.Route (routeUrlRel)
+import Rib.Shake (ribOutputDir)
+import System.FilePath
+import System.Info (os)
+import System.Posix.Process
+
+openLocallyGeneratedFile :: OpenCommand -> Action ()
+openLocallyGeneratedFile OpenCommand {..} = do
+  let relHtmlPath = T.unpack $ routeUrlRel `foldSome` route
+      opener = if os == "darwin" then "open" else "xdg-open"
+  htmlPath <- fmap (</> relHtmlPath) ribOutputDir
+  doesFileExist htmlPath >>= \case
+    False -> do
+      fail "No generated HTML found. Try runing `neuron rib` first."
+    True -> do
+      liftIO $ executeFile opener True [htmlPath] Nothing
diff --git a/src/app/Neuron/CLI/Rib.hs b/src/app/Neuron/CLI/Rib.hs
--- a/src/app/Neuron/CLI/Rib.hs
+++ b/src/app/Neuron/CLI/Rib.hs
@@ -33,7 +33,7 @@
     mkRibCliConfig :: FilePath -> RibConfig -> IO Rib.Cli.CliConfig
     mkRibCliConfig inputDir cfg = do
       unlessM (doesDirectoryExist inputDir) $ do
-        fail $ "Zettelkasten directory " <> inputDir <> " does not exist."
+        fail $ "Zettelkasten directory (" <> inputDir <> ") does not exist. You may create it manually using `mkdir " <> inputDir <> "` before running neuron."
       let neuronDir = inputDir </> ".neuron"
           outputDir = fromMaybe (neuronDir </> "output") $ ribOutputDir cfg
           rebuildAll = False
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
@@ -11,7 +11,14 @@
   )
 where
 
+import qualified Data.Text as Text
+import Development.Shake (Action)
 import Neuron.CLI.Rib
+  ( SearchBy (SearchByContent, SearchByTitle),
+    SearchCommand (..),
+  )
+import Neuron.Config.Type (Config, getZettelFormats)
+import Neuron.Reader.Type (ZettelFormat (ZettelFormat_Org), zettelFormatToExtension)
 import Relude
 import System.Posix.Process
 import System.Which
@@ -19,19 +26,24 @@
 neuronSearchScript :: FilePath
 neuronSearchScript = $(staticWhich "neuron-search")
 
-searchScriptArgs :: SearchCommand -> [String]
-searchScriptArgs SearchCommand {..} =
-  let searchByArgs =
+searchScriptArgs :: (NonEmpty ZettelFormat) -> SearchCommand -> [String]
+searchScriptArgs formats SearchCommand {..} =
+  let extensionPattern = "/*{" <> (Text.unpack $ Text.intercalate "," $ toList $ zettelFormatToExtension <$> formats) <> "}"
+      searchByArgs =
         case searchBy of
-          SearchByTitle -> ["(^# )|(^title: )", "2"]
-          SearchByContent -> ["", "2"]
+          SearchByTitle -> ["(^# )|(^title: )", "2", extensionPattern]
+          SearchByContent -> ["", "2", extensionPattern]
       editArg =
         bool "echo" "$EDITOR" searchEdit
    in searchByArgs <> [editArg]
 
-interactiveSearch :: FilePath -> SearchCommand -> IO ()
-interactiveSearch notesDir searchCmd =
-  execScript neuronSearchScript $ notesDir : searchScriptArgs searchCmd
+interactiveSearch :: FilePath -> SearchCommand -> Config -> Action ()
+interactiveSearch notesDir searchCmd config =
+  do
+    zettelFormats <- getZettelFormats config
+    if searchBy searchCmd == SearchByTitle && ZettelFormat_Org `elem` toList zettelFormats
+      then fail "search is not supported for .org files"
+      else liftIO $ execScript neuronSearchScript $ notesDir : searchScriptArgs zettelFormats searchCmd
   where
     execScript scriptPath args =
       -- We must use the low-level execvp (via the unix package's `executeFile`)
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
@@ -12,6 +12,8 @@
     NewCommand (..),
     SearchBy (..),
     SearchCommand (..),
+    OpenCommand (..),
+    QueryCommand (..),
     RibConfig (..),
     commandParser,
   )
@@ -21,15 +23,21 @@
 import Data.Some
 import Data.TagTree (mkTagPattern)
 import Data.Time
+import Data.Time.DateMayTime
+  ( DateMayTime,
+    formatDateMayTime,
+    mkDateMayTime,
+    parseDateMayTime,
+  )
 import Neuron.Reader.Type (ZettelFormat)
+import qualified Neuron.Web.Route as R
 import qualified Neuron.Zettelkasten.Connection as C
-import Neuron.Zettelkasten.ID (ZettelID, parseZettelID')
+import Neuron.Zettelkasten.ID (ZettelID, parseZettelID)
 import Neuron.Zettelkasten.ID.Scheme (IDScheme (..))
 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
@@ -41,9 +49,8 @@
   }
 
 data NewCommand = NewCommand
-  { title :: Maybe Text,
-    format :: Maybe ZettelFormat,
-    day :: Day,
+  { format :: Maybe ZettelFormat,
+    date :: DateMayTime,
     idScheme :: Some IDScheme,
     edit :: Bool
   }
@@ -60,15 +67,27 @@
   | SearchByContent
   deriving (Eq, Show)
 
+data OpenCommand = OpenCommand
+  { route :: Some R.Route
+  }
+  deriving (Eq, Show)
+
+data QueryCommand = QueryCommand
+  { -- Use cache instead of building the zettelkasten from scratch
+    cached :: Bool,
+    query :: Either (Some Q.ZettelQuery) (Some Q.GraphQuery)
+  }
+  deriving (Eq, Show)
+
 data Command
   = -- | Create a new zettel file
     New NewCommand
   | -- | Open the locally generated Zettelkasten
-    Open
+    Open OpenCommand
   | -- | Search a zettel by title
     Search SearchCommand
   | -- | Run a query against the Zettelkasten
-    Query (Either (Some Q.ZettelQuery) (Some Q.GraphQuery))
+    Query QueryCommand
   | -- | Delegate to Rib's command parser
     Rib RibConfig
 
@@ -82,13 +101,13 @@
   deriving (Eq, Show)
 
 -- | optparse-applicative parser for neuron CLI
-commandParser :: FilePath -> Day -> Parser App
-commandParser defaultNotesDir today = do
+commandParser :: FilePath -> LocalTime -> Parser App
+commandParser defaultNotesDir now = do
   notesDir <-
     option
       Rib.Cli.directoryReader
-      ( long "zettelkasten-dir" <> short 'd' <> metavar "NOTESDIR" <> value defaultNotesDir <> showDefault
-          <> help "Your zettelkasten directory containing the zettel files"
+      ( short 'd' <> metavar "PATH" <> value defaultNotesDir
+          <> help "Run as if neuron was started in PATH instead of the current working directory"
       )
   cmd <- cmdParser
   pure $ App {..}
@@ -103,68 +122,77 @@
             command "rib" $ info ribCommand $ progDesc "Generate static site via rib"
           ]
     newCommand = do
-      title <- optional $ strArgument (metavar "TITLE" <> help "Title of the new Zettel")
+      idScheme <-
+        fmap (maybe (Some IDSchemeHash) (Some . IDSchemeCustom)) $
+          optional $
+            strArgument (metavar "TITLEID" <> help "Custom (title) ID to use; otherwise random ID will be generated")
       format <-
-        optional
-          $ option auto
-          $ metavar "FORMAT"
-            <> short 'f'
-            <> long "format"
-            <> help "The document format of the new zettel"
+        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 $
-          long "day"
-            <> metavar "DAY"
-            <> value today
-            <> showDefault
-            <> help "Zettel creation date in UTC"
-      -- NOTE: optparse-applicative picks the first option as the default.
-      idSchemeF <-
+      dateParam <-
+        option dateReader $
+          long "date"
+            <> metavar "DATE/TIME"
+            <> value (mkDateMayTime $ Right now)
+            <> showDefaultWith (toString . formatDateMayTime)
+            <> help "Zettel date/time"
+      pure $ New $ NewCommand format dateParam idScheme edit
+    openCommand = do
+      fmap Open $
         fmap
-          (const $ const $ Some IDSchemeHash)
-          (switch (long "id-hash" <> help "Use random hash ID (default)"))
+          (const $ OpenCommand $ Some $ R.Route_ZIndex)
+          (switch (long "zindex" <> help "Open z-index"))
           <|> fmap
-            (const $ Some . IDSchemeDate)
-            (switch (long "id-date" <> help "Use date encoded ID"))
+            (const $ OpenCommand $ Some $ R.Route_Search Nothing)
+            (switch (long "search" <> help "Open the search page"))
           <|> fmap
-            (const . Some . IDSchemeCustom)
-            (option str (long "id" <> help "Use a custom ID" <> metavar "IDNAME"))
-      pure $ New $ NewCommand title format day (idSchemeF day) edit
-    openCommand =
-      pure Open
-    queryCommand =
-      fmap Query $
-        ( 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')
+            (OpenCommand . Some . R.Route_Zettel)
+            (option zettelIDReader (long "id" <> help "Open the zettel HTML page" <> metavar "ID"))
+    queryCommand = do
+      cached <- switch (long "cached" <> help "Use cached zettelkasten graph (faster)")
+      query <-
+        fmap
+          Left
+          ( fmap
+              (Some . flip Q.ZettelQuery_ZettelByID connDummy)
+              (option zettelIDReader (long "id"))
+              <|> fmap
+                (\x -> Some $ Q.ZettelQuery_ZettelsByTag x connDummy 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
+                  (Some . Q.GraphQuery_BacklinksOf Nothing)
+                  ( option
+                      zettelIDReader
+                      ( long "backlinks-of"
+                          <> help "Get backlinks to the given zettel ID"
+                          <> metavar "ID"
+                      )
+                  )
+                <|> fmap
+                  (Some . Q.GraphQuery_BacklinksOf (Just C.Folgezettel))
+                  ( option
+                      zettelIDReader
+                      ( long "uplinks-of"
+                          <> help "Get uplinks to the given zettel ID"
+                          <> metavar "ID"
+                      )
+                  )
             )
-            <|> 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"
-                    )
-              )
-        )
+      pure $ Query $ QueryCommand {..}
     searchCommand = do
       searchBy <-
         bool SearchByTitle SearchByContent
@@ -186,14 +214,20 @@
       pure RibConfig {..}
     zettelIDReader :: ReadM ZettelID
     zettelIDReader =
-      eitherReader $ first show . parseZettelID' . toText
+      eitherReader $ first show . parseZettelID . toText
     queryReader :: ReadM (Some Q.ZettelQuery)
     queryReader =
       eitherReader $ \(toText -> s) -> case URI.mkURI s of
         Right uri ->
-          either (Left . toString . Q.showQueryParseError) (maybe (Left "Unsupported query") Right) $ Q.queryFromURI uri
+          either
+            (Left . toString . Q.showQueryParseError)
+            (maybe (Left "Unsupported query") Right)
+            $ Q.queryFromURI connDummy uri
         Left e ->
           Left $ displayException e
-    dayReader :: ReadM Day
-    dayReader =
-      maybeReader (parseZettelDate . toText)
+    dateReader :: ReadM DateMayTime
+    dateReader =
+      maybeReader (parseDateMayTime . toText)
+    -- We don't care about connections in the CLI, but the query requires one -
+    -- so pass a dummy value.
+    connDummy = C.OrdinaryConnection
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
@@ -18,28 +18,35 @@
 where
 
 import Data.Either.Validation (validationToEither)
+import qualified Data.Text as T
 import Development.Shake (Action, readFile')
-import qualified Dhall
 import Dhall (FromDhall)
-import qualified Dhall.Core
-import qualified Dhall.Parser
-import qualified Dhall.TypeCheck
+import qualified Dhall (Decoder (extract), auto)
+import qualified Dhall.Core (normalize)
+import qualified Dhall.Parser (exprFromText)
+import qualified Dhall.TypeCheck (typeOf)
 import Neuron.Config.Orphans ()
 import Neuron.Config.Type (Config, configFile, defaultConfig, mergeWithDefault)
 import Relude
-import qualified Rib
-import System.Directory
-import System.FilePath
+import Rib.Shake (ribInputDir)
+import System.Directory (doesFileExist)
+import System.FilePath ((</>))
 
 -- | Read the optional @neuron.dhall@ config file from the zettelkasten
 getConfig :: Action Config
 getConfig = do
-  configPath <- Rib.ribInputDir <&> (</> configFile)
-  configVal :: Text <- liftIO (doesFileExist configPath) >>= \case
-    True -> do
-      mergeWithDefault . toText <$> readFile' configPath
-    False ->
-      pure defaultConfig
+  notesDir <- ribInputDir
+  let configPath = notesDir </> configFile
+  configVal :: Text <-
+    liftIO (doesFileExist configPath) >>= \case
+      True -> do
+        s <- readFile' configPath
+        -- Accept empty neuron.dhall (used to signify a directory to be used with neuron)
+        if T.null (T.strip $ toText s)
+          then pure defaultConfig
+          else mergeWithDefault . toText <$> readFile' configPath
+      False ->
+        fail $ "not a neuron notes directory (no neuron.dhall found under " <> notesDir <> ")"
   either fail pure $ parsePure configFile $ mergeWithDefault configVal
 
 -- | Pure version of `Dhall.input Dhall.auto`
@@ -50,7 +57,7 @@
   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
+  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
@@ -5,6 +5,7 @@
 module Neuron.Config.Alias where
 
 import Control.Monad.Except (liftEither, runExcept, throwError)
+import Data.List (delete)
 import Neuron.Config.Type
 import qualified Neuron.Zettelkasten.Graph as G
 import Neuron.Zettelkasten.Graph.Type (ZettelGraph)
@@ -32,20 +33,24 @@
       pure v
   where
     hasIndexZettel =
-      isJust . G.getZettel (parseZettelID "index")
+      isJust . G.getZettel indexZid
 
 mkAliases :: [Text] -> ZettelGraph -> Either Text [Alias]
 mkAliases aliasSpecs graph =
-  sequence $ flip fmap aliasSpecs $ \aliasSpec -> runExcept $ do
-    alias@Alias {..} <- liftEither $ parse aliasParser configFile aliasSpec
-    when (isJust $ G.getZettel aliasZettel graph) $ do
-      throwError $
-        "Cannot create redirect from '" <> zettelIDText aliasZettel <> "', because a zettel with that ID already exists"
-    when (zettelIDText targetZettel /= "z-index" && isNothing (G.getZettel targetZettel graph)) $ do
-      throwError $
-        "Target zettel '" <> zettelIDText targetZettel <> "' does not exist"
-    pure alias
+  sequence $
+    flip fmap aliasSpecs $ \aliasSpec -> runExcept $ do
+      alias@Alias {..} <- liftEither $ parse aliasParser configFile aliasSpec
+      when (isJust $ G.getZettel aliasZettel graph) $ do
+        throwError $
+          "Cannot create redirect from '" <> zettelIDRaw aliasZettel <> "', because a zettel with that ID already exists"
+      when (zettelIDSlug targetZettel /= "z-index" && isNothing (G.getZettel targetZettel graph)) $ do
+        throwError $
+          "Target zettel '" <> zettelIDRaw targetZettel <> "' does not exist"
+      pure alias
 
 aliasParser :: Parser Alias
 aliasParser =
-  Alias <$> (idParser <* M.char ':') <*> idParser
+  -- Disallow ':' in IDs, because we use colon as a separator in alias spec.
+  -- TODO: Do proper parsing so colon is allowed in both IDs.
+  let idParserSansColon = idParser' $ delete ':' allowedSpecialChars
+   in Alias <$> (idParserSansColon <* M.char ':') <*> idParser
diff --git a/src/app/Neuron/Reader/Org.hs b/src/app/Neuron/Reader/Org.hs
--- a/src/app/Neuron/Reader/Org.hs
+++ b/src/app/Neuron/Reader/Org.hs
@@ -15,13 +15,14 @@
 import qualified Data.Map as Map
 import Data.TagTree (Tag (Tag))
 import Data.Tagged
-import Data.Time.Calendar (Day)
+import Data.Text (toLower)
+import Data.Time.DateMayTime (DateMayTime, parseDateMayTime)
 import Neuron.Reader.Type (ZettelParseError, ZettelReader)
-import Neuron.Zettelkasten.Zettel.Meta (Meta (..), parseZettelDate)
+import Neuron.Zettelkasten.Zettel.Meta (Meta (..))
 import Relude
 import Relude.Extra.Map (lookup)
 import Text.Pandoc (def, runPure)
-import Text.Pandoc.Definition hiding (Meta (..))
+import Text.Pandoc.Definition (Pandoc)
 import Text.Pandoc.Readers.Org (readOrg)
 import Text.Pandoc.Util (getH1)
 
@@ -39,8 +40,11 @@
     -- title is now deprecated
     let title = Nothing
         tags = fmap Tag . words <$> lookup "tags" properties
+        unlisted = parseUnlisted <$> lookup "unlisted" properties
     pure $ Just Meta {..}
   | otherwise = pure Nothing
   where
-    parseDate :: Text -> Either ZettelParseError Day
-    parseDate date = maybeToRight (Tagged $ "Invalid date format: " <> date) $ parseZettelDate @Maybe date
+    parseDate :: Text -> Either ZettelParseError DateMayTime
+    parseDate date = maybeToRight (Tagged $ "Invalid date format: " <> date) $ parseDateMayTime @Maybe date
+    parseUnlisted :: Text -> Bool
+    parseUnlisted a = toLower a == "true"
diff --git a/src/app/Neuron/Web/Cache.hs b/src/app/Neuron/Web/Cache.hs
new file mode 100644
--- /dev/null
+++ b/src/app/Neuron/Web/Cache.hs
@@ -0,0 +1,53 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- | Responsible for caching zettelkasten graph on disk
+module Neuron.Web.Cache where
+
+import Data.Aeson (eitherDecodeFileStrict, encodeFile)
+import Development.Shake (Action)
+import Neuron.Config.Type (Config)
+import Neuron.Zettelkasten.Graph.Type (ZettelGraph)
+import Neuron.Zettelkasten.ID (ZettelID)
+import Neuron.Zettelkasten.Zettel (ZettelError)
+import Relude
+import Rib.Shake
+import System.Directory (createDirectoryIfMissing)
+import System.FilePath
+
+data ReadMode
+  = ReadMode_Direct Config
+  | ReadMode_Cached
+  deriving (Eq, Show)
+
+type CacheData = (ZettelGraph, Map ZettelID ZettelError)
+
+cacheFile :: Action FilePath
+cacheFile = do
+  neuronDir <- (</> ".neuron") <$> ribInputDir
+  liftIO $ createDirectoryIfMissing True neuronDir
+  pure $ neuronDir </> "cache.json"
+
+updateCache :: CacheData -> Action ()
+updateCache v = do
+  f <- cacheFile
+  liftIO $ encodeFile f v
+
+getCache :: Action CacheData
+getCache = do
+  (liftIO . eitherDecodeFileStrict =<< cacheFile) >>= \case
+    Left err -> fail err
+    Right v -> pure v
+
+evalUnlessCacheRequested ::
+  ReadMode -> (Config -> Action CacheData) -> Action CacheData
+evalUnlessCacheRequested mode f = do
+  case mode of
+    ReadMode_Direct config ->
+      f config
+    ReadMode_Cached -> do
+      (liftIO . eitherDecodeFileStrict =<< cacheFile) >>= \case
+        Left err -> fail err
+        Right v -> pure v
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
@@ -14,10 +14,11 @@
 module Neuron.Web.Generate
   ( generateSite,
     loadZettelkasten,
+    loadZettelkastenGraph,
   )
 where
 
-import Data.FileEmbed (embedStringFile)
+import Data.FileEmbed (embedOneStringFileOf)
 import qualified Data.Map.Strict as Map
 import Data.Tagged (untag)
 import qualified Data.Text as T
@@ -28,6 +29,7 @@
 import Neuron.Reader (readerForZettelFormat)
 import Neuron.Reader.Type (ZettelFormat, zettelFormatToExtension)
 import Neuron.Version (neuronVersion, olderThan)
+import qualified Neuron.Web.Cache as Cache
 import Neuron.Web.Generate.Route ()
 import qualified Neuron.Web.Route as Z
 import qualified Neuron.Zettelkasten.Graph.Build as G
@@ -37,12 +39,16 @@
 import Neuron.Zettelkasten.Zettel
 import Options.Applicative
 import Relude
-import qualified Rib
 import Rib.Route
+import Rib.Shake (forEvery, ribInputDir)
 import System.FilePath
 
+-- | The contents of search.js
+--
+-- We specify an alternate path, that is relative to project root, so that
+-- ghcide will be able to compile this module.
 searchScript :: Text
-searchScript = $(embedStringFile "./src-js/search.js")
+searchScript = $(embedOneStringFileOf ["./src-js/search.js", "./neuron/src-js/search.js"])
 
 -- | Generate the Zettelkasten site
 generateSite ::
@@ -51,11 +57,12 @@
   Action ZettelGraph
 generateSite config writeHtmlRoute' = do
   when (olderThan $ minVersion config) $ do
-    fail $ toString $
-      "Require neuron mininum version "
-        <> minVersion config
-        <> ", but your neuron version is "
-        <> neuronVersion
+    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)
@@ -65,7 +72,7 @@
   -- Generate the z-index
   writeHtmlRoute errors Z.Route_ZIndex
   -- Generate search page
-  writeHtmlRoute searchScript Z.Route_Search
+  writeHtmlRoute searchScript $ Z.Route_Search Nothing
   -- Write alias redirects, unless a zettel with that name exists.
   aliases <- getAliases config zettelGraph
   forM_ aliases $ \Alias {..} ->
@@ -99,6 +106,16 @@
         go (x : xs) =
           x : fmap (toText . (take n (repeat ' ') <>) . toString) xs
 
+-- | Like `loadZettelkasten` but without the content
+--
+-- Also allows retrieving the cached data for faster execution.
+loadZettelkastenGraph ::
+  Config ->
+  Action (ZettelGraph, Map ZettelID ZettelError)
+loadZettelkastenGraph config = do
+  (g, _, errs) <- loadZettelkasten config
+  pure (g, errs)
+
 loadZettelkasten ::
   Config ->
   Action
@@ -110,9 +127,11 @@
   formats <- getZettelFormats config
   zettelFiles <- forM formats $ \fmt -> do
     let pat = toString $ "*" <> zettelFormatToExtension fmt
-    files <- Rib.forEvery [pat] pure
+    files <- forEvery [pat] pure
     pure (fmt, files)
-  loadZettelkastenFrom zettelFiles
+  res@(g, _, errs) <- loadZettelkastenFrom zettelFiles
+  Cache.updateCache (g, errs)
+  pure res
 
 -- | Load the Zettelkasten from disk, using the given list of zettel files
 loadZettelkastenFrom ::
@@ -123,38 +142,39 @@
       Map ZettelID ZettelError
     )
 loadZettelkastenFrom fs = do
-  notesDir <- Rib.ribInputDir
+  notesDir <- 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)))
+    fmap snd $
+      flip runStateT Map.empty $ do
+        forM_ fs $ \(format, files) -> do
+          forM_ files $ \relPath -> do
+            case getZettelID format 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)])
+        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
--- a/src/app/Neuron/Web/Generate/Route.hs
+++ b/src/app/Neuron/Web/Generate/Route.hs
@@ -15,11 +15,14 @@
 
 import Control.Monad.Except
 import Data.Some
+import Data.TagTree (unTag)
+import qualified Network.URI.Encode as E
 import Neuron.Web.Route (Route (..), RouteConfig (..))
 import Neuron.Zettelkasten.ID
 import Reflex.Dom.Core
 import Relude
-import Rib (IsRoute (..), routeUrl, routeUrlRel)
+import Rib.Route (IsRoute (..), routeUrlRel)
+import Text.URI (URI, mkURI)
 import qualified Text.URI as URI
 
 instance IsRoute Route where
@@ -28,9 +31,9 @@
       routeFile $ Route_Zettel zid
     Route_ZIndex ->
       pure "z-index.html"
-    Route_Search ->
+    Route_Search _mtag ->
       pure "search.html"
-    Route_Zettel (zettelIDText -> s) ->
+    Route_Zettel (zettelIDSlug -> s) ->
       pure $ toString s <> ".html"
 
 staticRouteConfig :: RouteConfig t m
@@ -46,7 +49,10 @@
       withSome someR $ \r -> do
         routeFor r
     -- Using relative URLs enables the site work in file:/// URLs
-    routeFor = routeUrlRel
+    routeFor = \case
+      -- HACK: Hack around Rib.Route's limitation in dealing with query arguments
+      r@(Route_Search (Just t)) -> routeUrlRel r <> "?tag=" <> unTag t
+      r -> routeUrlRel r
 
 data BaseUrlError
   = BaseUrlNotAbsolute
@@ -55,10 +61,16 @@
 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
+routeUri :: (HasCallStack, IsRoute r) => URI -> r a -> URI
+routeUri baseUrl r = either (error . toText . displayException) id $
+  runExcept $ do
+    let -- We use routeUrlRel, rather than routeUrl, to avoid the leading '/' which
+        -- will get encoded by `E.encode`, creating incorrect URL encoding.
+        relUrlUnicode = routeUrlRel r
+        -- Use `E.encode` to deal with unicode code points, as mkURI will fail on them.
+        -- This is necessary to support non-ascii characters in filenames
+        relUrl = toText . E.encode . toString $ relUrlUnicode
+    uri <- liftEither $ mkURI relUrl
+    case URI.relativeTo uri baseUrl of
+      Nothing -> liftEither $ Left $ toException BaseUrlNotAbsolute
+      Just x -> pure x
diff --git a/src/app/Neuron/Web/HeadHtml.hs b/src/app/Neuron/Web/HeadHtml.hs
new file mode 100644
--- /dev/null
+++ b/src/app/Neuron/Web/HeadHtml.hs
@@ -0,0 +1,38 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Web.HeadHtml
+  ( HeadHtml,
+    getHeadHtml,
+    renderHeadHtml,
+  )
+where
+
+import Development.Shake
+import Reflex.Dom.Core
+import Reflex.Dom.Pandoc (PandocBuilder)
+import Reflex.Dom.Pandoc.PandocRaw (PandocRaw (..))
+import Relude
+import Rib.Shake (ribInputDir)
+import System.FilePath ((</>))
+import Text.Pandoc.Definition (Format (..))
+
+newtype HeadHtml = HeadHtml (Maybe Text)
+
+getHeadHtml :: Action HeadHtml
+getHeadHtml = do
+  headHtmlPath <- ribInputDir <&> (</> "head.html")
+  doesFileExist headHtmlPath >>= \case
+    True ->
+      HeadHtml . Just . toText <$> readFile' headHtmlPath
+    False ->
+      pure $ HeadHtml Nothing
+
+renderHeadHtml :: PandocBuilder t m => HeadHtml -> m ()
+renderHeadHtml (HeadHtml headHtml) = case headHtml of
+  Nothing ->
+    -- Include the MathJax script if no custom head.html is provided.
+    elAttr "script" ("id" =: "MathJax-script" <> "src" =: "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js" <> "async" =: "") blank
+  Just html ->
+    elPandocRaw (Format "html") html
diff --git a/src/app/Neuron/Web/StructuredData.hs b/src/app/Neuron/Web/StructuredData.hs
--- a/src/app/Neuron/Web/StructuredData.hs
+++ b/src/app/Neuron/Web/StructuredData.hs
@@ -1,5 +1,6 @@
 {-# LANGUAGE GADTs #-}
 {-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE NamedFieldPuns #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RecordWildCards #-}
 {-# LANGUAGE ScopedTypeVariables #-}
@@ -12,9 +13,10 @@
 
 import Data.Structured.Breadcrumb (Breadcrumb)
 import qualified Data.Structured.Breadcrumb as Breadcrumb
+import Data.Structured.OpenGraph
+import Data.Structured.OpenGraph.Render (renderOpenGraph)
 import qualified Data.Text as T
-import Data.Time.ISO8601 (formatISO8601)
-import Neuron.Config.Type
+import Neuron.Config.Type (Config (..), getSiteBaseUrl)
 import Neuron.Web.Generate.Route (routeUri)
 import Neuron.Web.Route
 import Neuron.Zettelkasten.Connection
@@ -23,11 +25,10 @@
 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.Definition (Block (Plain), Inline (Image), Pandoc (..))
 import Text.Pandoc.Util (getFirstParagraphText)
+import Text.Pandoc.Walk (query)
 import qualified Text.URI as URI
 
 renderStructuredData :: DomBuilder t m => Config -> Route a -> (ZettelGraph, a) -> m ()
@@ -36,9 +37,9 @@
   Breadcrumb.renderBreadcrumbs $ routeStructuredData config val route
 
 routeStructuredData :: Config -> (ZettelGraph, a) -> Route a -> [Breadcrumb]
-routeStructuredData Config {..} (graph, v) = \case
+routeStructuredData cfg (graph, v) = \case
   Route_Zettel _ ->
-    case siteBaseUrl of
+    case (either fail id $ getSiteBaseUrl cfg) of
       Nothing -> []
       Just baseUrl ->
         let mkCrumb :: Zettel -> Breadcrumb.Item
@@ -49,14 +50,14 @@
     []
 
 routeOpenGraph :: Config -> a -> Route a -> OpenGraph
-routeOpenGraph Config {..} v r =
+routeOpenGraph cfg@Config {siteTitle, author} 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_Search _mtag) -> Just "Search Zettelkasten"
         Route_Zettel _ -> do
           doc <- getPandocDoc v
           para <- getFirstParagraphText doc
@@ -69,47 +70,24 @@
       _openGraph_image = case r of
         Route_Zettel _ -> do
           doc <- getPandocDoc v
-          image <- URI.mkURI =<< Pandoc.getFirstImg doc
-          baseUrl <- URI.mkURI =<< siteBaseUrl
+          image <- URI.mkURI =<< getFirstImg doc
+          baseUrl <- either fail id $ getSiteBaseUrl cfg
           URI.relativeTo image baseUrl
         _ -> Nothing,
       _openGraph_url = do
-        baseUrl <- siteBaseUrl
+        baseUrl <- either fail id $ getSiteBaseUrl cfg
         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'
+    getFirstImg ::
+      Pandoc ->
+      -- | Relative URL path to the image
+      Maybe Text
+    getFirstImg (Pandoc _ bs) = listToMaybe $
+      flip query bs $ \case
+        Image _ _ (url, _) -> [toText url]
+        _ -> []
 
 renderPandocAsText :: [Inline] -> Maybe Text
 renderPandocAsText =
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
@@ -18,16 +18,17 @@
   )
 where
 
-import Clay ((?), Css, em, gray, important, pct, px)
+import Clay (Css, em, gray, important, pct, px, (?))
 import qualified Clay as C
 import Control.Monad.Except
-import Data.Aeson ((.=), object, toJSON)
+import Data.Aeson (object, toJSON, (.=))
 import qualified Data.Aeson.Text as Aeson
 import qualified Data.Set as Set
 import Data.Some
 import Data.TagTree (Tag (..))
 import Neuron.Config.Type (Config (..))
 import Neuron.Web.Common (neuronCommonStyle, neuronFonts)
+import Neuron.Web.HeadHtml (HeadHtml, renderHeadHtml)
 import Neuron.Web.Manifest (Manifest, renderManifest)
 import qualified Neuron.Web.Query.View as QueryView
 import Neuron.Web.Route
@@ -38,9 +39,9 @@
 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 (..))
+import qualified Neuron.Zettelkasten.Graph as G
+import Neuron.Zettelkasten.ID (indexZid)
 import Neuron.Zettelkasten.Zettel
 import Reflex.Dom.Core hiding ((&))
 import Reflex.Dom.Pandoc (PandocBuilder)
@@ -49,16 +50,18 @@
 import qualified Skylighting.Styles as Skylighting
 
 -- | Render the given route
-renderRoutePage :: PandocBuilder t m => Text -> Config -> Manifest -> Route a -> (ZettelGraph, a) -> NeuronWebT t m ()
-renderRoutePage neuronVersion config manifest r val =
+renderRoutePage :: PandocBuilder t m => Text -> Config -> HeadHtml -> Manifest -> Route a -> (ZettelGraph, a) -> NeuronWebT t m ()
+renderRoutePage neuronVersion config headHtml manifest r val = do
+  -- DOCTYPE declaration is helpful for code that might appear in the user's `head.html` file (e.g. KaTeX).
+  el "!DOCTYPE html" $ blank
   elAttr "html" ("lang" =: "en") $ do
     el "head" $ do
-      renderRouteHead config manifest r val
+      renderRouteHead config headHtml manifest r val
     el "body" $ do
       renderRouteBody neuronVersion config r val
 
-renderRouteHead :: DomBuilder t m => Config -> Manifest -> Route a -> (ZettelGraph, a) -> m ()
-renderRouteHead config manifest route val = do
+renderRouteHead :: PandocBuilder t m => Config -> HeadHtml -> Manifest -> Route a -> (ZettelGraph, a) -> NeuronWebT t m ()
+renderRouteHead config headHtml 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
@@ -70,7 +73,7 @@
       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/fomantic-ui@2.8.7/dist/semantic.min.js",
           "https://cdn.jsdelivr.net/npm/js-search@2.0.0/dist/umd/js-search.min.js"
         ]
         $ \scrpt -> do
@@ -82,11 +85,10 @@
   where
     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 "link" ("rel" =: "stylesheet" <> "href" =: "https://cdn.jsdelivr.net/npm/fomantic-ui@2.8.7/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
+      renderHeadHtml headHtml
     routeTitle :: Config -> a -> Route a -> Text
     routeTitle Config {..} v =
       withSuffix siteTitle . routeTitle' v
@@ -100,7 +102,7 @@
 renderRouteBody neuronVersion Config {..} r (g, x) = do
   let neuronTheme = Theme.mkTheme theme
       themeSelector = toText $ Theme.themeIdentifier neuronTheme
-      indexZettel = G.getZettel (ZettelCustomID "index") g
+      indexZettel = G.getZettel indexZid g
   elAttr "div" ("class" =: "ui fluid container" <> "id" =: themeSelector) $ do
     case r of
       Route_ZIndex -> do
@@ -153,7 +155,7 @@
       [ "id" .= toJSON zettelID,
         "title" .= zettelTitle,
         "tags" .= zettelTags,
-        "day" .= zettelDay
+        "day" .= zettelDate
       ]
 
 renderBrandFooter :: DomBuilder t m => Maybe Text -> m ()
@@ -180,7 +182,7 @@
     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
+    neuronRouteLink (Some $ Route_Search Nothing) ("class" =: "left item" <> "title" =: "Search Zettels") $ do
       semanticIcon "search"
     forM_ mEditUrl $ \editUrl -> do
       let attrs = ("href" =: editUrl <> "title" =: "Edit this Zettel")
diff --git a/src/app/Neuron/Zettelkasten/ID/Scheme.hs b/src/app/Neuron/Zettelkasten/ID/Scheme.hs
--- a/src/app/Neuron/Zettelkasten/ID/Scheme.hs
+++ b/src/app/Neuron/Zettelkasten/ID/Scheme.hs
@@ -5,14 +5,20 @@
 {-# LANGUAGE TemplateHaskell #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 
-module Neuron.Zettelkasten.ID.Scheme where
+-- TODO: Simplify or eliminate this module, now that date IDs are gone.
+module Neuron.Zettelkasten.ID.Scheme
+  ( nextAvailableZettelID,
+    genVal,
+    IDScheme (..),
+    IDConflict (..),
+  )
+where
 
 import Control.Monad.Except
 import Data.GADT.Compare.TH
 import Data.GADT.Show.TH
 import qualified Data.Set as Set
 import qualified Data.Text as T
-import Data.Time
 import Data.UUID (UUID)
 import qualified Data.UUID as UUID
 import Data.UUID.V4 (nextRandom)
@@ -21,14 +27,15 @@
 import Text.Megaparsec.Simple
 import Text.Show
 
+-- | The scheme to use when generating new IDs
 data IDScheme a where
-  IDSchemeDate :: Day -> IDScheme ()
+  -- | Random IDs (default)
   IDSchemeHash :: IDScheme UUID
+  -- | Custom ID (specified by the user)
   IDSchemeCustom :: Text -> IDScheme ()
 
 data IDConflict
   = IDConflict_AlreadyExists
-  | IDConflict_DateIDExhausted
   | IDConflict_HashConflict Text
   | IDConflict_BadCustomID Text Text
   deriving (Eq)
@@ -37,53 +44,45 @@
   show = \case
     IDConflict_AlreadyExists ->
       "A zettel with that ID already exists"
-    IDConflict_DateIDExhausted ->
-      "Ran out of date ID indices for this day"
     IDConflict_HashConflict s ->
       "Hash conflict on " <> toString s <> "; try again"
     IDConflict_BadCustomID s e ->
       "The custom ID " <> toString s <> " is malformed: " <> toString e
 
--- | Produce a value that is required to run an ID scheme.
+-- | Produce a value that is required ahead to run an ID scheme.
 genVal :: forall a. IDScheme a -> IO a
 genVal = \case
   IDSchemeHash ->
     nextRandom
-  IDSchemeDate _ ->
-    pure ()
   IDSchemeCustom _ ->
     pure ()
 
--- | Create a new zettel ID based on the given scheme without conflicting with
--- the IDs of existing zettels.
+-- | Create a new zettel ID based on the given scheme
+--
+-- This is a pure function, with all impure actions done in @genVal@
+--
+-- Ensures that new ID doesn't conflict with existing zettels.
 nextAvailableZettelID ::
   forall a.
   -- Existing zettels
   Set ZettelID ->
+  -- Seed value for the scheme
   a ->
+  -- Scheme to use when generating an ID
   IDScheme a ->
   Either IDConflict ZettelID
 nextAvailableZettelID zs val = \case
-  IDSchemeDate day -> do
-    let dayIndices = nonEmpty $ sort $ flip mapMaybe (Set.toList zs) $ \case
-          ZettelDateID d x
-            | d == day -> Just x
-          _ -> Nothing
-    case last <$> dayIndices of
-      Nothing -> pure $ ZettelDateID day 1
-      Just 99 -> throwError IDConflict_DateIDExhausted
-      Just idx -> pure $ ZettelDateID day (idx + 1)
   IDSchemeHash -> do
     let s = T.take 8 $ UUID.toText val
-    if s `Set.member` (zettelIDText `Set.map` zs)
+    if s `Set.member` (zettelIDSlug `Set.map` zs)
       then throwError $ IDConflict_HashConflict s
       else
-        either (error . toText) (pure . ZettelCustomID) $
-          parse customIDParser "<random-hash>" s
+        either (error . toText) pure $
+          parse idParser "<random-hash>" s
   IDSchemeCustom s -> runExcept $ do
     zid <-
-      either (throwError . IDConflict_BadCustomID s) (pure . ZettelCustomID) $
-        parse customIDParser "<next-id>" s
+      either (throwError . IDConflict_BadCustomID s) pure $
+        parse idParser "<next-id>" s
     if zid `Set.member` zs
       then throwError IDConflict_AlreadyExists
       else pure zid
diff --git a/src/exe/Main.hs b/src/exe/Main.hs
new file mode 100644
--- /dev/null
+++ b/src/exe/Main.hs
@@ -0,0 +1,46 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Main where
+
+import Control.Monad.Reader
+import Development.Shake
+import Main.Utf8
+import Neuron.CLI (run)
+import Neuron.Config.Type (Config)
+import Neuron.Version (neuronVersion)
+import Neuron.Web.Generate (generateSite)
+import Neuron.Web.Generate.Route (staticRouteConfig)
+import Neuron.Web.HeadHtml (getHeadHtml)
+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 Rib.Route (writeRoute)
+import Rib.Shake (buildStaticFiles, ribInputDir)
+
+main :: IO ()
+main = withUtf8 $ run generateMainSite
+
+generateMainSite :: Config -> Action ()
+generateMainSite config = do
+  notesDir <- ribInputDir
+  buildStaticFiles ["static/**", ".nojekyll"]
+  manifest <- fmap Manifest.mkManifest $ getDirectoryFiles notesDir Manifest.manifestPatterns
+  headHtml <- getHeadHtml
+  let writeHtmlRoute :: Route a -> (ZettelGraph, a) -> Action ()
+      writeHtmlRoute r x = do
+        html <- liftIO $
+          fmap snd $
+            renderStatic $ do
+              runNeuronWeb staticRouteConfig $
+                renderRoutePage neuronVersion config headHtml manifest r x
+        -- FIXME: Make rib take bytestrings
+        writeRoute r $ decodeUtf8 @Text html
+  void $ generateSite config writeHtmlRoute
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
@@ -21,12 +21,14 @@
     preSetWithEdgeLabelMany,
     topSort,
     clusters,
+    dfsForest,
     dfsForestFrom,
     dfsForestBackwards,
     bfsForestFrom,
     bfsForestBackwards,
     obviateRootUnlessForest,
     induceOnEdge,
+    induce,
   )
 where
 
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
@@ -75,19 +75,26 @@
 
 topSort :: (Vertex v, Ord (VertexID v)) => LabelledGraph v e -> Either (NonEmpty v) [v]
 topSort g =
-  bimap (fmap (getVertex g)) (fmap (getVertex g))
-    $ Algo.topSort
-    $ LAM.skeleton
-    $ graph g
+  bimap (fmap (getVertex g)) (fmap (getVertex g)) $
+    Algo.topSort $
+      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.
+--
+-- If any of the cluster is cyclic, that cluster will not be included.
 clusters :: (Vertex v, Ord (VertexID v)) => LabelledGraph v e -> [NonEmpty v]
 clusters g =
   fmap (fmap $ getVertex g) $ mothers $ LAM.skeleton $ graph g
 
 -- | Compute the dfsForest from the given vertices.
+dfsForest :: (Vertex v, Ord (VertexID v)) => LabelledGraph v e -> Forest v
+dfsForest g =
+  fmap (fmap $ getVertex g) $ Algo.dfsForest $ LAM.skeleton $ graph g
+
+-- | Compute the dfsForest from the given vertices.
 dfsForestFrom :: (Vertex v, Ord (VertexID v)) => [v] -> LabelledGraph v e -> Forest v
 dfsForestFrom (fmap vertexID -> vs) g =
   fmap (fmap $ getVertex g) $ Algo.dfsForestFrom vs $ LAM.skeleton $ graph g
@@ -110,6 +117,12 @@
 --------------------------
 --- More general utilities
 --------------------------
+
+induce :: Ord (VertexID v) => (VertexID v -> Bool) -> LabelledGraph v e -> LabelledGraph v e
+induce f (LabelledGraph g v) =
+  LabelledGraph g' v
+  where
+    g' = LAM.induce f g
 
 -- | Like `induce` but operates on edges instead of vertices
 induceOnEdge :: Ord (VertexID v) => (e -> Bool) -> LabelledGraph v e -> LabelledGraph v e
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
@@ -30,7 +30,9 @@
 import qualified Text.Megaparsec.Char as M
 import Text.Megaparsec.Simple
 
--- | Tag metadata field in Zettel notes
+-- | A hierarchical tag
+--
+-- Tag nodes are separated by @/@
 newtype Tag = Tag {unTag :: Text}
   deriving (Eq, Ord, Show, ToJSON, FromJSON, FromYAML, ToYAML)
 
@@ -38,9 +40,16 @@
 -- Tag Pattern
 ---------------
 
--- | Glob-based pattern matching of tags
+-- | A glob-based pattern to match hierarchical tags
 --
--- Eg.: "foo/**" matches both "foo/bar/baz" and "foo/baz"
+-- For example, the pattern
+--
+-- > foo/**
+--
+-- matches both the following
+--
+-- > foo/bar/baz
+-- > foo/baz
 newtype TagPattern = TagPattern {unTagPattern :: FilePattern}
   deriving (Eq, Show, ToJSON, FromJSON)
 
@@ -62,7 +71,13 @@
 -- Tag Tree
 -----------
 
--- | A tag like "foo/bar/baz" is split into three nodes "foo", "bar" and "baz."
+-- | An individual component of a hierarchical tag
+--
+-- The following hierarchical tag,
+--
+-- > foo/bar/baz
+--
+-- has three tag nodes: @foo@, @bar@ and @baz@
 newtype TagNode = TagNode {unTagNode :: Text}
   deriving (Eq, Show, Ord, ToJSON)
 
@@ -81,13 +96,13 @@
 constructTag (fmap unTagNode . toList -> nodes) =
   Tag $ T.intercalate "/" nodes
 
--- | Construct a tree from a list of tags
+-- | Construct the tree from a list of hierarchical tags
 tagTree :: ann ~ Natural => Map Tag ann -> Forest (TagNode, ann)
 tagTree tags =
-  fmap (annotatePathsWith $ countFor tags)
-    $ mkTreeFromPaths
-    $ fmap (toList . deconstructTag)
-    $ Map.keys tags
+  fmap (annotatePathsWith $ countFor tags) $
+    mkTreeFromPaths $
+      fmap (toList . deconstructTag) $
+        Map.keys tags
   where
     countFor tags' path =
       fromMaybe 0 $ Map.lookup (constructTag path) tags'
diff --git a/src/lib/Data/Time/DateMayTime.hs b/src/lib/Data/Time/DateMayTime.hs
new file mode 100644
--- /dev/null
+++ b/src/lib/Data/Time/DateMayTime.hs
@@ -0,0 +1,80 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TupleSections #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE ViewPatterns #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module Data.Time.DateMayTime
+  ( -- Date type
+    DateMayTime,
+    mkDateMayTime,
+    getDay,
+    -- Date formatting
+    dateTimeFormat,
+    formatDay,
+    formatLocalTime,
+    formatDateMayTime,
+    -- Date parsing
+    parseDateMayTime,
+  )
+where
+
+import Data.Aeson (FromJSON, ToJSON)
+import Data.Time
+import Data.YAML
+import Relude
+
+-- | Like `Day` but with optional time.
+newtype DateMayTime = DateMayTime {unDateMayTime :: (Day, Maybe TimeOfDay)}
+  deriving (Eq, Show, Generic, Ord, ToJSON, FromJSON)
+
+instance FromYAML DateMayTime where
+  parseYAML =
+    parseDateMayTime <=< parseYAML @Text
+
+instance ToYAML DateMayTime where
+  toYAML =
+    toYAML . formatDateMayTime
+
+mkDateMayTime :: Either Day LocalTime -> DateMayTime
+mkDateMayTime =
+  DateMayTime . \case
+    Left day ->
+      (day, Nothing)
+    Right datetime ->
+      localDay &&& Just . localTimeOfDay $ datetime
+
+getDay :: DateMayTime -> Day
+getDay = fst . unDateMayTime
+
+formatDateMayTime :: DateMayTime -> Text
+formatDateMayTime (DateMayTime (day, mtime)) =
+  maybe (formatDay day) (formatLocalTime . LocalTime day) mtime
+
+formatDay :: Day -> Text
+formatDay = formatTime' dateFormat
+
+formatLocalTime :: LocalTime -> Text
+formatLocalTime = formatTime' dateTimeFormat
+
+parseDateMayTime :: (MonadFail m, Alternative m) => Text -> m DateMayTime
+parseDateMayTime (toString -> s) = do
+  fmap mkDateMayTime $
+    fmap Left (parseTimeM False defaultTimeLocale dateFormat s)
+      <|> fmap Right (parseTimeM False defaultTimeLocale dateTimeFormat s)
+
+dateFormat :: String
+dateFormat = "%Y-%m-%d"
+
+dateTimeFormat :: String
+dateTimeFormat = "%Y-%m-%dT%H:%M"
+
+-- | Like `formatTime` but with default time locale and returning Text
+formatTime' :: FormatTime t => String -> t -> Text
+formatTime' s = toText . formatTime defaultTimeLocale s
diff --git a/src/lib/Neuron/Config/Type.hs b/src/lib/Neuron/Config/Type.hs
--- a/src/lib/Neuron/Config/Type.hs
+++ b/src/lib/Neuron/Config/Type.hs
@@ -13,6 +13,7 @@
     defaultConfig,
     mergeWithDefault,
     getZettelFormats,
+    getSiteBaseUrl,
   )
 where
 
@@ -20,20 +21,20 @@
 import Neuron.Reader.Type (ZettelFormat)
 import Relude hiding (readEither)
 import Text.Read (readEither)
+import Text.URI (URI, mkURI)
 
 configFile :: FilePath
 configFile = "neuron.dhall"
 
 -- | Config type for @neuron.dhall@
 --
--- See <https://neuron.zettel.page/2011701.html guide> for description of the fields.
+-- See <https://neuron.zettel.page/configuration.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,
@@ -48,6 +49,16 @@
   formats' <- maybe (fail "Empty formats") pure $ nonEmpty formats
   traverse (either (fail . toString) pure . readEither . toString) formats'
 
+getSiteBaseUrl :: MonadFail m => Config -> m (Maybe URI)
+getSiteBaseUrl Config {..} =
+  runMaybeT $ do
+    s <- MaybeT $ pure siteBaseUrl
+    case mkURI s of
+      Left e ->
+        fail $ displayException e
+      Right uri ->
+        pure uri
+
 defaultConfig :: Text
 defaultConfig =
   "{ siteTitle =\
@@ -64,8 +75,6 @@
   \   [] : List Text\
   \, formats =\
   \   [ \"markdown\" ]\
-  \, mathJaxSupport =\
-  \   True\
   \, minVersion =\
   \   \"0.5\" \
   \}"
diff --git a/src/lib/Neuron/Reader/Markdown.hs b/src/lib/Neuron/Reader/Markdown.hs
--- a/src/lib/Neuron/Reader/Markdown.hs
+++ b/src/lib/Neuron/Reader/Markdown.hs
@@ -1,10 +1,9 @@
 {-# LANGUAGE GADTs #-}
-{-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
 {-# LANGUAGE RankNTypes #-}
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE TupleSections #-}
-{-# LANGUAGE ViewPatterns #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 
 module Neuron.Reader.Markdown
@@ -17,11 +16,9 @@
 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 ()
@@ -30,11 +27,19 @@
 import Relude hiding (show, traceShowId)
 import qualified Text.Megaparsec as M
 import qualified Text.Megaparsec.Char as M
-import Text.Megaparsec.Simple
+import Text.Megaparsec.Simple (Parser, parse)
 import qualified Text.Pandoc.Builder as B
 import Text.Pandoc.Definition (Pandoc (..))
 import qualified Text.Parsec as P
-import Text.Show
+import Text.Show (Show (show))
+import Text.URI
+  ( QueryParam (QueryFlag),
+    URI (URI, uriQuery),
+    mkPathPiece,
+    mkURI,
+    render,
+  )
+import Text.URI.QQ (queryKey, scheme)
 
 -- | Parse Markdown document, along with the YAML metadata block in it.
 --
@@ -99,14 +104,18 @@
     CE.HasFootnote il bl,
     CE.HasMath il,
     CE.HasDefinitionList il bl,
-    CE.HasDiv bl
+    CE.HasDiv bl,
+    CE.HasQuoted il,
+    CE.HasSpan il
   ) =>
   CM.SyntaxSpec m il bl
 neuronSpec =
   mconcat
-    [ wrappedLinkSpec angleBracketLinkP,
-      wrappedLinkSpec wikiLinkP,
+    [ autoLinkSpec,
+      wikiLinkSpec,
+      inlineTagSpec,
       gfmExtensionsSansEmoji,
+      CE.fancyListSpec,
       CE.footnoteSpec,
       CE.mathSpec,
       CE.smartPunctuationSpec,
@@ -114,6 +123,7 @@
       CE.attributesSpec,
       CE.rawAttributeSpec,
       CE.fencedDivSpec,
+      CE.bracketedSpanSpec,
       CM.defaultSyntaxSpec {CM.syntaxBlockSpecs = defaultBlockSpecsSansRawHtml}
     ]
   where
@@ -124,40 +134,97 @@
         <> CE.autoIdentifiersSpec
         <> CE.taskListSpec
 
+inlineTagSpec ::
+  (Monad m, CM.IsBlock il bl, CM.IsInline il) =>
+  CM.SyntaxSpec m il bl
+inlineTagSpec =
+  mempty
+    { CM.syntaxInlineParsers = [pInlineTag]
+    }
+  where
+    pInlineTag ::
+      (Monad m, CM.IsInline il) =>
+      CM.InlineParser m il
+    pInlineTag = P.try $ do
+      _ <- symbol '#'
+      tag <- CM.untokenize <$> inlineTagP
+      let tagQuery = "z:tag/" <> tag
+      pure $! cmAutoLink tagQuery
+
 -- | Convert the given wrapped link to a `B.Link`.
-wrappedLinkSpec ::
+autoLinkSpec ::
   (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 =
+autoLinkSpec =
   mempty
-    { CM.syntaxInlineParsers = [pLink linkP]
+    { CM.syntaxInlineParsers = [pLink]
     }
   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
+    pLink = P.try $ do
+      x <- angleBracketLinkP
       let url = CM.untokenize x
-          title = ""
-      pure $! CM.link url title $ CM.str url
+      pure $! cmAutoLink 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
+-- | Create a commonmark link element
+cmAutoLink :: CM.IsInline a => Text -> a
+cmAutoLink url =
+  CM.link url title $ CM.str url
+  where
+    title = ""
 
-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
+wikiLinkSpec ::
+  (Monad m, CM.IsBlock il bl, CM.IsInline il) =>
+  CM.SyntaxSpec m il bl
+wikiLinkSpec =
+  mempty
+    { CM.syntaxInlineParsers = [pLink]
+    }
+  where
+    pLink ::
+      (Monad m, CM.IsInline il) =>
+      CM.InlineParser m il
+    pLink = P.try $ do
+      fmap cmAutoLink $
+        P.choice
+          [ -- Folgezettel link: [[[...]]]
+            P.try (wikiLinkP 3),
+            -- Cf link: [[...]]
+            P.try (wikiLinkP 2)
+          ]
+    wikiLinkP :: Monad m => Int -> P.ParsecT [CM.Tok] s m Text
+    wikiLinkP n = do
+      void $ M.count n $ symbol '['
+      s <- fmap CM.untokenize $ some $ noneOfToks [Symbol ']', LineEnd]
+      -- Parse as URI, add cf flag, and then render back. If parse fails, we
+      -- just ignore this inline.
+      case parseNeuronUri s of
+        Just uri -> do
+          void $ M.count n $ symbol ']'
+          pure $
+            render $ case n of
+              2 ->
+                -- [[..]] adds "cf" flag in URI
+                uri {uriQuery = uriQuery uri <> [QueryFlag [queryKey|cf|]]}
+              _ -> uri
+        Nothing ->
+          fail "Not a neuron URI; ignoring"
+    parseNeuronUri :: Text -> Maybe URI
+    parseNeuronUri s =
+      case toString s of
+        ('z' : ':' : _) ->
+          mkURI s
+        _ -> do
+          -- Treat it as plain ID
+          path <- mkPathPiece s
+          pure $ URI (Just [scheme|z|]) (Left True) (Just (False, path :| [])) [] Nothing
 
+inlineTagP :: Monad m => P.ParsecT [CM.Tok] s m [CM.Tok]
+inlineTagP =
+  some (noneOfToks [Symbol ']', Spaces, UnicodeSpace, LineEnd])
+
 -- rawHtmlSpec eats angle bracket links as html tags
 defaultBlockSpecsSansRawHtml :: (Monad m, CM.IsBlock il bl) => [CM.BlockSpec m il bl]
 defaultBlockSpecsSansRawHtml =
@@ -179,4 +246,16 @@
 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}
+  CM.rawHtmlSpec
+    { CM.blockStart = P.notFollowedBy angleBracketLinkP >> CM.blockStart CM.rawHtmlSpec
+    }
+
+angleBracketLinkP :: Monad m => P.ParsecT [CM.Tok] s m [CM.Tok]
+angleBracketLinkP = do
+  void $ symbol '<'
+  -- NOTE: Intentionally be lenient to support `<z:zettels?t...>` style
+  -- queries. FIXME: Should fail on `</foo>` though (HTML end tags). TODO:
+  -- Add unit tests before modifying this matching any further.
+  x <- some (noneOfToks [Symbol '>', Spaces, UnicodeSpace, LineEnd])
+  void $ symbol '>'
+  pure x
diff --git a/src/lib/Neuron/Reader/Type.hs b/src/lib/Neuron/Reader/Type.hs
--- a/src/lib/Neuron/Reader/Type.hs
+++ b/src/lib/Neuron/Reader/Type.hs
@@ -22,6 +22,7 @@
 import Text.Read
 import Prelude (show)
 
+-- | A function that parses the zettel into a Pandoc AST
 type ZettelReader = FilePath -> Text -> Either ZettelParseError (Maybe Meta, Pandoc)
 
 type ZettelParseError = Tagged "ZettelParserError" Text
diff --git a/src/lib/Neuron/Web/Common.hs b/src/lib/Neuron/Web/Common.hs
--- a/src/lib/Neuron/Web/Common.hs
+++ b/src/lib/Neuron/Web/Common.hs
@@ -4,7 +4,7 @@
 
 module Neuron.Web.Common where
 
-import Clay ((?), Css)
+import Clay (Css, (?))
 import qualified Clay as C
 import Relude
 
@@ -12,6 +12,9 @@
 neuronCommonStyle = do
   C.important $ C.backgroundColor "#eee"
   C.important $ C.fontFamily [bodyFont] [C.serif]
+  ".ui.container" ? do
+    -- Override Semantic UI's font
+    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
@@ -21,10 +24,10 @@
 neuronFonts = [headerFont, bodyFont, monoFont]
 
 headerFont :: Text
-headerFont = "DM Serif Text"
+headerFont = "Kanit"
 
 bodyFont :: Text
-bodyFont = "DM Sans"
+bodyFont = "Ubuntu"
 
 monoFont :: Text
-monoFont = "DM Mono"
+monoFont = "Roboto Mono"
diff --git a/src/lib/Neuron/Web/Query/View.hs b/src/lib/Neuron/Web/Query/View.hs
--- a/src/lib/Neuron/Web/Query/View.hs
+++ b/src/lib/Neuron/Web/Query/View.hs
@@ -2,11 +2,9 @@
 {-# LANGUAGE GADTs #-}
 {-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE QuasiQuotes #-}
 {-# LANGUAGE Rank2Types #-}
 {-# LANGUAGE RecordWildCards #-}
 {-# LANGUAGE ScopedTypeVariables #-}
-{-# LANGUAGE TypeApplications #-}
 {-# LANGUAGE ViewPatterns #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 
@@ -14,63 +12,86 @@
   ( renderQueryResult,
     renderZettelLink,
     renderZettelLinkIDOnly,
-    zettelUrl,
-    tagUrl,
     style,
   )
 where
 
+import Clay (Css, em, (?))
 import qualified Clay as C
-import Clay ((?), Css, em)
-import Control.Monad.Except
-import Data.Default
-import Data.Dependent.Sum
+import Data.Dependent.Sum (DSum (..))
 import qualified Data.Map.Strict as Map
-import Data.Some
-import Data.TagTree (Tag (..), TagNode (..), TagPattern (..), constructTag, foldTagTree, tagMatchAny, tagTree)
+import Data.Some (Some (..))
+import Data.TagTree
+  ( Tag (..),
+    TagNode (..),
+    TagPattern (..),
+    constructTag,
+    foldTagTree,
+    tagMatchAny,
+    tagTree,
+  )
 import qualified Data.Text as T
-import Data.Tree
+import Data.Tree (Forest, Tree (Node))
 import Neuron.Web.Route
-import Neuron.Web.Widget
-import Neuron.Zettelkasten.Connection
-import Neuron.Zettelkasten.ID
+  ( NeuronWebT,
+    Route (..),
+    neuronRouteLink,
+  )
+import Neuron.Web.Widget (elTime, semanticIcon)
+import Neuron.Zettelkasten.Connection (Connection (Folgezettel))
+import Neuron.Zettelkasten.ID (ZettelID (zettelIDRaw))
 import Neuron.Zettelkasten.Query.Theme (LinkView (..), ZettelsView (..))
 import Neuron.Zettelkasten.Zettel
+  ( Zettel,
+    ZettelQuery (..),
+    ZettelT (..),
+    sortZettelsReverseChronological,
+  )
 import Reflex.Dom.Core hiding (count, tag)
+import Reflex.Dom.Pandoc (PandocBuilder, elPandocInlines)
 import Relude
+import Text.Pandoc.Definition (Inline)
 
 -- | 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
+  PandocBuilder t m => Maybe [Inline] -> DSum ZettelQuery Identity -> NeuronWebT t m ()
+renderQueryResult minner = \case
+  ZettelQuery_ZettelByID _zid conn :=> Identity target -> do
+    renderZettelLink (elPandocInlines <$> minner) (Just conn) Nothing target
+  q@(ZettelQuery_ZettelsByTag pats 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
+          el "ul" $
+            forM_ res $ \z -> do
+              el "li" $
+                renderZettelLink Nothing (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
+              el "ul" $
+                forM_ zettelGrp $ \z ->
+                  el "li" $
+                    renderZettelLink Nothing (Just conn) (Just $ zettelsViewLinkView view) z
   q@(ZettelQuery_Tags _) :=> Identity res -> do
     el "section" $ do
       renderQuery $ Some q
       renderTagTree $ foldTagTree $ tagTree res
+  ZettelQuery_TagZettel tag :=> Identity () ->
+    renderInlineTag tag mempty $ do
+      text "#"
+      text $ unTag tag
   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]
+      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 =
@@ -91,10 +112,21 @@
       Some (ZettelQuery_Tags (fmap unTagPattern -> pats)) -> do
         let qs = toText $ intercalate ", " pats
         text $ "Tags matching '" <> qs <> "'"
+      Some (ZettelQuery_TagZettel _tag) -> do
+        blank
 
 -- | 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
+renderZettelLink ::
+  DomBuilder t m =>
+  -- | Link inner text
+  Maybe (m ()) ->
+  -- | Connection type to display
+  Maybe Connection ->
+  -- | Link theme
+  Maybe LinkView ->
+  Zettel ->
+  NeuronWebT t m ()
+renderZettelLink mInner conn (fromMaybe def -> linkView) Zettel {..} = do
   let connClass = show <$> conn
       rawClass = either (const $ Just "raw") (const Nothing) zettelError
       mextra =
@@ -102,28 +134,38 @@
           LinkView_Default ->
             Nothing
           LinkView_ShowDate ->
-            elTime <$> zettelDay
+            elTime <$> zettelDate
           LinkView_ShowID ->
-            Just $ el "tt" $ text $ zettelIDText zettelID
+            Just $ el "tt" $ text $ zettelIDRaw 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
+      elNoSnippetSpan ("class" =: "extra monoFont") $ do
         extra
         -- The extra space is so that double clicking on this extra text
         -- doesn't select the title next.
         text " "
-    let linkTooltip =
+    elAttr "span" ("class" =: "zettel-link" <> withTooltip linkTooltip) $ do
+      let linkInnerHtml = fromMaybe (text zettelTitle) mInner
+      neuronRouteLink (Some $ Route_Zettel zettelID) mempty linkInnerHtml
+      case conn of
+        Just Folgezettel -> elNoSnippetSpan mempty $ do
+          elAttr "sup" ("title" =: "Branching link (folgezettel)") $ text "ᛦ"
+        _ -> pure mempty
+  where
+    linkTooltip =
+      -- If there is custom inner text, put zettel title in tooltip.
+      -- Otherwise put tags if any.
+      if isJust mInner
+        then Just $ "Zettel: " <> zettelTitle
+        else
           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" =: ""
+    elNoSnippetSpan :: DomBuilder t m => Map Text Text -> NeuronWebT t m a -> NeuronWebT t m a
+    elNoSnippetSpan attrs = elAttr "span" ("data-nosnippet" =: "" <> attrs)
     withTooltip :: Maybe Text -> Map Text Text
     withTooltip = \case
       Nothing -> mempty
@@ -138,29 +180,29 @@
 renderZettelLinkIDOnly zid =
   elClass "span" "zettel-link-container" $ do
     elClass "span" "zettel-link" $ do
-      neuronRouteLink (Some $ Route_Zettel zid) mempty $ text $ zettelIDText zid
+      neuronRouteLink (Some $ Route_Zettel zid) mempty $ text $ zettelIDRaw zid
 
-renderTagTree :: forall t m. DomBuilder t m => Forest (NonEmpty TagNode, Natural) -> m ()
+renderTagTree :: forall t m. DomBuilder t m => Forest (NonEmpty TagNode, Natural) -> NeuronWebT t m ()
 renderTagTree t =
   divClass "tag-tree" $
     renderForest mempty t
   where
-    renderForest :: [TagNode] -> Forest (NonEmpty TagNode, Natural) -> m ()
+    renderForest :: [TagNode] -> Forest (NonEmpty TagNode, Natural) -> NeuronWebT t m ()
     renderForest ancestors forest =
       el "ul" $ do
         forM_ forest $ \tree ->
           el "li" $ renderTree ancestors tree
-    renderTree :: [TagNode] -> Tree (NonEmpty TagNode, Natural) -> m ()
+    renderTree :: [TagNode] -> Tree (NonEmpty TagNode, Natural) -> NeuronWebT t 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 :: [TagNode] -> (NonEmpty TagNode, Natural) -> NeuronWebT t 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
+        renderInlineTag tag ("class" =: cls <> "title" =: tit) $
           text $ renderTagNode tagNode
     renderTagNode :: NonEmpty TagNode -> Text
     renderTagNode = \case
@@ -171,14 +213,9 @@
           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
+renderInlineTag :: DomBuilder t m => Tag -> Map Text Text -> m () -> NeuronWebT t m ()
+renderInlineTag tag attr body =
+  neuronRouteLink (Some $ Route_Search $ Just tag) attr body
 
 style :: Css
 style = do
@@ -196,9 +233,6 @@
     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
diff --git a/src/lib/Neuron/Web/Route.hs b/src/lib/Neuron/Web/Route.hs
--- a/src/lib/Neuron/Web/Route.hs
+++ b/src/lib/Neuron/Web/Route.hs
@@ -2,6 +2,7 @@
 {-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RankNTypes #-}
+{-# LANGUAGE TemplateHaskell #-}
 {-# LANGUAGE TypeFamilies #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 
@@ -9,7 +10,10 @@
 module Neuron.Web.Route where
 
 import Control.Monad.Reader
+import Data.GADT.Compare.TH
+import Data.GADT.Show.TH
 import Data.Some
+import Data.TagTree (Tag)
 import Neuron.Zettelkasten.ID
 import Neuron.Zettelkasten.Zettel
 import Reflex.Dom.Core
@@ -21,7 +25,9 @@
   -- `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
+  -- The tag argument is only used in rendering the URL, and not when writing the file.
+  -- TODO: Fix this bad use of types.
+  Route_Search :: Maybe Tag -> Route Text
   Route_Zettel :: ZettelID -> Route ZettelC
 
 data RouteConfig t m = RouteConfig
@@ -57,6 +63,10 @@
 routeTitle' v = \case
   Route_Redirect _ -> "Redirecting..."
   Route_ZIndex -> "Zettel Index"
-  Route_Search -> "Search"
+  Route_Search _mtag -> "Search"
   Route_Zettel _ ->
     either zettelTitle zettelTitle v
+
+deriveGEq ''Route
+
+deriveGShow ''Route
diff --git a/src/lib/Neuron/Web/Theme.hs b/src/lib/Neuron/Web/Theme.hs
--- a/src/lib/Neuron/Web/Theme.hs
+++ b/src/lib/Neuron/Web/Theme.hs
@@ -13,7 +13,7 @@
   )
 where
 
-import Clay ((?), Css, rgb, rgba)
+import Clay (Css, rgb, rgba, (?))
 import qualified Clay as C
 import Data.Text (toLower)
 import Relude
@@ -41,14 +41,14 @@
 -- | 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
+  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
diff --git a/src/lib/Neuron/Web/Widget.hs b/src/lib/Neuron/Web/Widget.hs
--- a/src/lib/Neuron/Web/Widget.hs
+++ b/src/lib/Neuron/Web/Widget.hs
@@ -1,21 +1,21 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE ViewPatterns #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 
 module Neuron.Web.Widget where
 
 import qualified Data.Text as T
-import Data.Time
-import Neuron.Zettelkasten.Zettel.Meta (formatZettelDate)
+import Data.Time.DateMayTime (DateMayTime, formatDateMayTime, formatDay, getDay)
 import Reflex.Dom.Core
 import Relude
 
 -- | <time> element
-elTime :: DomBuilder t m => Day -> m ()
+elTime :: DomBuilder t m => DateMayTime -> 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
+  elAttr "time" ("datetime" =: formatDateMayTime t) $ do
+    text $ formatDay $ getDay t
 
 semanticIcon :: DomBuilder t m => Text -> m ()
 semanticIcon name = elClass "i" (name <> " icon") blank
diff --git a/src/lib/Neuron/Web/Widget/InvertedTree.hs b/src/lib/Neuron/Web/Widget/InvertedTree.hs
--- a/src/lib/Neuron/Web/Widget/InvertedTree.hs
+++ b/src/lib/Neuron/Web/Widget/InvertedTree.hs
@@ -91,22 +91,24 @@
       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
+      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
+        forM_ [C.after, C.before] $ \sel ->
+          sel & do
+            C.display none
       C.firstChild & do
         C.before & do
           C.borderStyle none
diff --git a/src/lib/Neuron/Web/ZIndex.hs b/src/lib/Neuron/Web/ZIndex.hs
--- a/src/lib/Neuron/Web/ZIndex.hs
+++ b/src/lib/Neuron/Web/ZIndex.hs
@@ -14,7 +14,7 @@
   )
 where
 
-import Clay ((?), Css, em)
+import Clay (Css, em, (?))
 import qualified Clay as C
 import Data.Foldable (maximum)
 import qualified Data.Map.Strict as Map
@@ -26,11 +26,11 @@
 import Neuron.Zettelkasten.Connection
 import Neuron.Zettelkasten.Graph (ZettelGraph)
 import qualified Neuron.Zettelkasten.Graph as G
-import Neuron.Zettelkasten.ID
+import Neuron.Zettelkasten.ID (ZettelID (..))
 import Neuron.Zettelkasten.Query (zettelsByTag)
 import Neuron.Zettelkasten.Query.Error (showQueryError)
 import Neuron.Zettelkasten.Zettel
-import Reflex.Dom.Core hiding ((&))
+import Reflex.Dom.Core hiding (mapMaybe, (&))
 import Relude hiding ((&))
 
 -- | The value needed to render the z-index
@@ -40,6 +40,7 @@
 data ZIndex = ZIndex
   { -- | Clusters on the folgezettel graph.
     zIndexClusters :: [Forest (Zettel, [Zettel])],
+    zIndexOrphans :: [Zettel],
     -- | All zettel errors
     zIndexErrors :: Map ZettelID ZettelError,
     zIndexStats :: Stats,
@@ -54,18 +55,23 @@
 
 buildZIndex :: ZettelGraph -> Map ZettelID ZettelError -> ZIndex
 buildZIndex graph errors =
-  let clusters = G.categoryClusters graph
-      clusters' :: [Forest (Zettel, [Zettel])] =
+  let (orphans, clusters) = partitionEithers $
+        flip fmap (G.categoryClusters graph) $ \case
+          [Node z []] -> Left z -- Orphans (cluster of exactly one)
+          x -> Right x
+      clustersWithBacklinks :: [Forest (Zettel, [Zettel])] =
+        -- Compute backlinks for each node in the tree.
         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
+   in ZIndex (fmap sortCluster clustersWithBacklinks) orphans 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
+      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
 
@@ -78,6 +84,26 @@
   elClass "h1" "header" $ text "Zettel Index"
   renderErrors zIndexErrors
   divClass "z-index" $ do
+    forM_ (nonEmpty zPinned) $ \zs ->
+      divClass "ui pinned raised segment" $ do
+        elClass "h3" "ui header" $ text "Pinned"
+        el "ul" $
+          forM_ zs $ \z ->
+            el "li" $ QueryView.renderZettelLink Nothing Nothing def z
+    whenNotNull zIndexOrphans $ \(toList -> zs) ->
+      divClass ("ui piled segment") $ do
+        elClass "h3" "ui header" $ text "Folgezettel Orphans"
+        elClass "p" "info" $ do
+          text "Notes without any "
+          elAttr "a" ("href" =: "https://neuron.zettel.page/linking.html") $ text "folgezettel"
+          text " relationships"
+        el "ul" $
+          forM_ zs $ \z ->
+            el "li" $ do
+              QueryView.renderZettelLink Nothing Nothing def z
+    forM_ zIndexClusters $ \forest ->
+      divClass ("ui " <> themeColor <> " segment") $ do
+        el "ul" $ renderForest forest
     el "p" $ do
       text $
         "The zettelkasten has "
@@ -86,15 +112,8 @@
           <> 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"
+      elAttr "a" ("href" =: "https://neuron.zettel.page/folgezettel-heterarchy.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
@@ -117,8 +136,8 @@
           text " has malformed queries"
         ZettelError_AmbiguousFiles _ -> do
           text $
-            "More than one file define the same zettel ID ("
-              <> zettelIDText zid
+            "More than one file define the same zettel ID slug ("
+              <> zettelIDSlug zid
               <> "):"
   forM_ (Map.toList errors) $ \(zid, zError) ->
     divClass ("ui tiny message " <> severity zError) $ do
@@ -143,7 +162,7 @@
 renderForest trees = do
   forM_ trees $ \(Node (zettel, uplinks) subtrees) ->
     el "li" $ do
-      QueryView.renderZettelLink Nothing def zettel
+      QueryView.renderZettelLink Nothing Nothing def zettel
       when (length uplinks >= 2) $ do
         elClass "span" "uplinks" $ do
           forM_ uplinks $ \z2 -> do
@@ -155,6 +174,8 @@
 style :: Css
 style = do
   "div.z-index" ? do
+    "p.info" ? do
+      C.color C.gray
     C.ul ? do
       C.listStyleType C.square
       C.paddingLeft $ em 1.5
diff --git a/src/lib/Neuron/Web/Zettel/CSS.hs b/src/lib/Neuron/Web/Zettel/CSS.hs
--- a/src/lib/Neuron/Web/Zettel/CSS.hs
+++ b/src/lib/Neuron/Web/Zettel/CSS.hs
@@ -40,11 +40,18 @@
 
 zettelContentCss :: Css
 zettelContentCss = do
+  ".pandoc" ? do
+    -- GitHub task list
+    ".ui.disabled.fitted.checkbox" ? do
+      C.marginRight $ em 0.3
+      C.verticalAlign C.middle
   ".zettel-content" ? do
     -- All of these apply to the zettel content card only.
-    "div.date" ? do
-      C.textAlign C.center
-      C.color C.gray
+    ".metadata" ? do
+      C.marginTop $ em 1
+      "div.date" ? do
+        C.textAlign C.center
+        C.color C.gray
     C.h1 ? do
       C.paddingTop $ em 0.2
       C.paddingBottom $ em 0.2
diff --git a/src/lib/Neuron/Web/Zettel/View.hs b/src/lib/Neuron/Web/Zettel/View.hs
--- a/src/lib/Neuron/Web/Zettel/View.hs
+++ b/src/lib/Neuron/Web/Zettel/View.hs
@@ -15,6 +15,7 @@
   )
 where
 
+import Data.Some
 import Data.TagTree
 import qualified Neuron.Web.Query.View as Q
 import Neuron.Web.Route
@@ -40,7 +41,7 @@
   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
+      Q.renderZettelLink Nothing (G.getConnection z z2 graph) def z2
   -- Main content
   elAttr "div" ("class" =: "ui text container" <> "id" =: "zettel-container" <> "style" =: "position: relative") $ do
     whenStaticallyGenerated $ do
@@ -69,21 +70,21 @@
 
 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
+  let backlinks = nonEmpty $ G.backlinks isJust 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
+  when (isJust backlinks || isJust tags) $
+    elClass "nav" "ui bottom attached segment deemphasized" $
+      do
+        divClass "ui grid" $ do
+          divClass "fourteen wide column" $ do
+            whenJust backlinks $ \links -> do
+              elAttr "div" ("class" =: "ui header" <> "title" =: "Zettels that link here (branching or not)") $
+                text "Backlinks"
+              el "ul" $ do
+                forM_ links $ \(conn, zl) ->
+                  el "li" $ Q.renderZettelLink Nothing (Just conn) def zl
+          whenJust tags $
+            divClass "two wide column" . renderTags
 
 evalAndRenderZettelQuery ::
   PandocBuilder t m =>
@@ -91,7 +92,7 @@
   NeuronWebT t m [QueryError] ->
   URILink ->
   NeuronWebT t m [QueryError]
-evalAndRenderZettelQuery graph oldRender uriLink = do
+evalAndRenderZettelQuery graph oldRender uriLink@(URILink inner _uri) = do
   case flip runReaderT (G.getZettels graph) (Q.runQueryURILink uriLink) of
     Left e -> do
       -- Error parsing or running the query.
@@ -100,7 +101,7 @@
       -- This is not a query link; pass through.
       oldRender
     Right (Just res) -> do
-      Q.renderQueryResult res
+      Q.renderQueryResult inner res
       pure mempty
   where
     elInlineError e =
@@ -118,10 +119,10 @@
     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
+    whenJust zettelDate $ \date ->
+      divClass "metadata" $ do
+        elAttr "div" ("class" =: "date" <> "title" =: "Zettel date") $ do
+          elTime date
 
 renderZettelRawContent :: (DomBuilder t m) => ZettelT Text -> m ()
 renderZettelRawContent Zettel {..} = do
@@ -131,19 +132,18 @@
   elClass "article" "ui raised attached segment zettel-content raw" $ do
     el "pre" $ text $ zettelContent
 
-renderTags :: DomBuilder t m => NonEmpty Tag -> m ()
+renderTags :: DomBuilder t m => NonEmpty Tag -> NeuronWebT t 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"
+      neuronRouteLink
+        (Some $ Route_Search $ Just t)
+        ( "class" =: "tag-inner"
             <> "title" =: ("See all zettels tagged '" <> unTag t <> "'")
         )
-        $ text
-        $ unTag t
+        $ do
+          text $ unTag t
     el "p" blank
diff --git a/src/lib/Neuron/Zettelkasten/Connection.hs b/src/lib/Neuron/Zettelkasten/Connection.hs
--- a/src/lib/Neuron/Zettelkasten/Connection.hs
+++ b/src/lib/Neuron/Zettelkasten/Connection.hs
@@ -6,7 +6,6 @@
 module Neuron.Zettelkasten.Connection where
 
 import Data.Aeson (FromJSON, ToJSON)
-import Data.Default
 import Relude hiding (show)
 import Text.Show
 
@@ -24,9 +23,6 @@
   Folgezettel <> _ = Folgezettel
   _ <> Folgezettel = Folgezettel
   OrdinaryConnection <> OrdinaryConnection = OrdinaryConnection
-
-instance Default Connection where
-  def = Folgezettel
 
 instance Show Connection where
   show = \case
diff --git a/src/lib/Neuron/Zettelkasten/Graph.hs b/src/lib/Neuron/Zettelkasten/Graph.hs
--- a/src/lib/Neuron/Zettelkasten/Graph.hs
+++ b/src/lib/Neuron/Zettelkasten/Graph.hs
@@ -14,21 +14,19 @@
     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 qualified Data.Set as Set
 import Data.Tree
 import Neuron.Zettelkasten.Connection
 import Neuron.Zettelkasten.Graph.Type
@@ -72,37 +70,41 @@
           (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
+categoryClusters (G.induceOnEdge (== Just Folgezettel) -> g) =
+  let cs :: [[Zettel]] = sortMothers $ G.clusters g
+      cleanClusters = flip G.bfsForestFrom g <$> cs
+      clusteredZettels :: [Zettel] =
+        (flatten `concatMap`) `concatMap` cleanClusters
+      unclustered =
+        Set.map zettelID $
+          Set.fromList (getZettels g)
+            `Set.difference` Set.fromList clusteredZettels
+      uncleanCluster =
+        G.dfsForest $
+          G.induce (flip Set.member unclustered) g
+   in cleanClusters
+        <> if null uncleanCluster
+          then mempty
+          else pure uncleanCluster
   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.
+-- | Return the connection if any between two zettels
+--
+-- 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
+getConnection z1 z2 g =
+  -- Use `join` so that empty edge monoid is treated as an abscence of edge
+  -- (connection)
+  join $ 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
--- a/src/lib/Neuron/Zettelkasten/Graph/Build.hs
+++ b/src/lib/Neuron/Zettelkasten/Graph/Build.hs
@@ -29,12 +29,12 @@
   )
 buildZettelkasten fs =
   let zs = parseZettels fs
-      (g, queryErrors) = mkZettelGraph $ sansContent <$> zs
+      (g, queryErrors) = mkZettelGraph $ filter (not . zettelUnlisted) $ sansContent <$> zs
       errors =
         Map.unions
-          [ fmap ZettelError_ParseError
-              $ Map.fromList
-              $ lefts zs <&> (zettelID &&& zettelError),
+          [ fmap ZettelError_ParseError $
+              Map.fromList $
+                lefts zs <&> (zettelID &&& zettelError),
             fmap ZettelError_QueryErrors queryErrors
           ]
    in (g, zs, errors)
@@ -49,23 +49,25 @@
     Map ZettelID (NonEmpty QueryError)
   )
 mkZettelGraph zettels =
-  let res :: [(Zettel, ([(Maybe Connection, Zettel)], [QueryError]))] =
+  let res :: [(Zettel, ([(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
+      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 :: [Zettel] -> Zettel -> ([(Connection, Zettel)], [QueryError])
 runQueryConnections zettels z =
   flip runReader zettels $ do
     runWriterT $ queryConnections z
 
-edgeFromConnection :: Zettel -> (Maybe Connection, Zettel) -> (Maybe Connection, Zettel, Zettel)
+edgeFromConnection :: Zettel -> (Connection, Zettel) -> (Maybe Connection, Zettel, Zettel)
 edgeFromConnection z (c, z2) =
-  (connectionMonoid $ fromMaybe Folgezettel c, z, z2)
+  (connectionMonoid c, z, z2)
   where
     -- Our connection monoid will never be Nothing (mempty); see the note in
     -- type `ZettelGraph`.
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
@@ -10,80 +10,75 @@
 module Neuron.Zettelkasten.ID
   ( ZettelID (..),
     InvalidID (..),
-    zettelIDText,
+    unsafeMkZettelID,
+    indexZid,
     parseZettelID,
-    parseZettelID',
+    allowedSpecialChars,
     idParser,
+    idParser',
     getZettelID,
     zettelIDSourceFileName,
-    customIDParser,
   )
 where
 
 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
 import qualified Text.Megaparsec.Char as M
 import Text.Megaparsec.Simple
-import Text.Printf
 import qualified Text.Show
 
-data ZettelID
-  = -- | Short Zettel ID encoding `Day` and a numeric index (on that day).
-    ZettelDateID Day Int
-  | -- | Arbitrary alphanumeric ID.
-    ZettelCustomID Text
-  deriving (Eq, Show, Ord, Generic)
+data ZettelID = ZettelID
+  { -- | Slug must be unique
+    zettelIDSlug :: Text,
+    -- | Actual ID used by the user, inside `[[..]]`
+    zettelIDRaw :: Text
+  }
+  deriving (Show, Ord, Generic)
 
+-- | Make ZettelID from raw text.
+--
+-- Assumes that input text is already validated for allowed characters.
+unsafeMkZettelID :: Text -> ZettelID
+unsafeMkZettelID s =
+  let slug = T.intercalate "_" $ T.splitOn " " s
+   in ZettelID slug s
+
+indexZid :: ZettelID
+indexZid = unsafeMkZettelID "index"
+
+instance Eq ZettelID where
+  (==) (ZettelID a _) (ZettelID b _) = a == b
+
 instance Show InvalidID where
   show (InvalidIDParseError s) =
     "Invalid Zettel ID: " <> toString s
 
+instance ToJSON ZettelID where
+  toJSON = toJSON . zettelIDRaw
+
 instance FromJSON ZettelID where
-  parseJSON x = do
-    s <- parseJSON x
-    case parseZettelID' s of
-      Left e -> fail $ show e
-      Right zid -> pure zid
+  parseJSON = fmap unsafeMkZettelID . parseJSON
 
 instance ToJSONKey ZettelID where
-  toJSONKey = toJSONKeyText zettelIDText
+  toJSONKey = toJSONKeyText zettelIDRaw
 
 instance FromJSONKey ZettelID where
   fromJSONKey = FromJSONKeyTextParser $ \s ->
-    case parseZettelID' s of
+    case parseZettelID s of
       Right v -> pure v
       Left e -> fail $ show e
 
-instance ToJSON ZettelID where
-  toJSON = toJSON . zettelIDText
-
-zettelIDText :: ZettelID -> Text
-zettelIDText = \case
-  ZettelDateID day idx ->
-    formatDay day <> toText @String (printf "%02d" idx)
-  ZettelCustomID s -> s
-
-formatDay :: Day -> Text
-formatDay day =
-  subDay $ toText $ formatTime defaultTimeLocale "%y%W%a" day
-  where
-    subDay =
-      T.replace "Mon" "1"
-        . T.replace "Tue" "2"
-        . T.replace "Wed" "3"
-        . T.replace "Thu" "4"
-        . T.replace "Fri" "5"
-        . T.replace "Sat" "6"
-        . T.replace "Sun" "7"
-
 zettelIDSourceFileName :: ZettelID -> ZettelFormat -> FilePath
-zettelIDSourceFileName zid fmt = toString $ zettelIDText zid <> zettelFormatToExtension fmt
+zettelIDSourceFileName zid fmt =
+  toString (fn <> ext)
+  where
+    fn = zettelIDRaw zid
+    ext = zettelFormatToExtension fmt
 
 ---------
 -- Parser
@@ -92,43 +87,39 @@
 data InvalidID = InvalidIDParseError Text
   deriving (Eq, Generic, ToJSON, FromJSON)
 
-parseZettelID :: HasCallStack => Text -> ZettelID
+parseZettelID :: Text -> Either InvalidID ZettelID
 parseZettelID =
-  either (error . show) id . parseZettelID'
-
-parseZettelID' :: Text -> Either InvalidID ZettelID
-parseZettelID' =
   first InvalidIDParseError . parse idParser "parseZettelID"
 
-idParser :: Parser ZettelID
-idParser =
-  M.try (fmap (uncurry ZettelDateID) $ dayParser <* M.eof)
-    <|> fmap ZettelCustomID customIDParser
+-- | Characters, aside from alpha numeric characters, to allow in IDs
+allowedSpecialChars :: [Char]
+allowedSpecialChars =
+  [ '_',
+    '-',
+    '.',
+    -- Whitespace is essential for title IDs
+    ' ',
+    -- Allow some puctuation letters that are common in note titles
+    ',',
+    ';',
+    '(',
+    ')',
+    ':',
+    '"',
+    '\''
+  ]
 
-dayParser :: Parser (Day, Int)
-dayParser = do
-  year <- parseNum 2
-  week <- parseNum 2
-  dayName <- dayFromIdx =<< parseNum 1
-  idx <- parseNum 2
-  day <-
-    parseTimeM False defaultTimeLocale "%y%W%a" $
-      printf "%02d" year <> printf "%02d" week <> dayName
-  pure (day, idx)
-  where
-    parseNum n = readNum =<< M.count n M.digitChar
-    readNum = maybe (fail "Not a number") pure . readMaybe
-    dayFromIdx :: MonadFail m => Int -> m String
-    dayFromIdx idx =
-      maybe (fail "Day should be a value from 1 to 7") pure $
-        ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] !!? (idx - 1)
+idParser :: Parser ZettelID
+idParser = idParser' allowedSpecialChars
 
-customIDParser :: Parser Text
-customIDParser = do
-  fmap toText $ M.some $ M.alphaNumChar <|> M.char '_' <|> M.char '-'
+idParser' :: String -> Parser ZettelID
+idParser' cs = do
+  s <- M.some $ M.alphaNumChar <|> M.choice (M.char <$> cs)
+  pure $ unsafeMkZettelID (toText s)
 
 -- | Parse the ZettelID if the given filepath is a zettel.
-getZettelID :: FilePath -> Maybe ZettelID
-getZettelID fp =
-  let (name, _) = splitExtension $ takeFileName fp
-   in rightToMaybe $ parseZettelID' $ toText name
+getZettelID :: ZettelFormat -> FilePath -> Maybe ZettelID
+getZettelID fmt fp = do
+  let (name, ext) = splitExtension $ takeFileName fp
+  guard $ zettelFormatToExtension fmt == toText ext
+  rightToMaybe $ parseZettelID $ toText name
diff --git a/src/lib/Neuron/Zettelkasten/Query.hs b/src/lib/Neuron/Zettelkasten/Query.hs
--- a/src/lib/Neuron/Zettelkasten/Query.hs
+++ b/src/lib/Neuron/Zettelkasten/Query.hs
@@ -45,6 +45,8 @@
     Right allTags
   ZettelQuery_Tags pats ->
     Right $ Map.filterWithKey (const . tagMatchAny pats) allTags
+  ZettelQuery_TagZettel _tag ->
+    Right ()
   where
     allTags :: Map.Map Tag Natural
     allTags =
@@ -53,9 +55,11 @@
 
 zettelsByTag :: [Zettel] -> [TagPattern] -> [Zettel]
 zettelsByTag zs pats =
-  sortZettelsReverseChronological $ flip filter zs $ \Zettel {..} ->
-    and $ flip fmap pats $ \pat ->
-      any (tagMatch pat) zettelTags
+  sortZettelsReverseChronological $
+    flip filter zs $ \Zettel {..} ->
+      and $
+        flip fmap pats $ \pat ->
+          any (tagMatch pat) zettelTags
 
 runGraphQuery :: ZettelGraph -> GraphQuery r -> Either QueryResultError r
 runGraphQuery g = \case
@@ -94,6 +98,8 @@
         toJSON r
       ZettelQuery_Tags _ ->
         toJSON $ fmap treeToJson . tagTree $ r
+      ZettelQuery_TagZettel _ ->
+        toJSON r
     treeToJson (Node (tag, count) children) =
       object
         [ "name" .= tag,
diff --git a/src/lib/Neuron/Zettelkasten/Query/Error.hs b/src/lib/Neuron/Zettelkasten/Query/Error.hs
--- a/src/lib/Neuron/Zettelkasten/Query/Error.hs
+++ b/src/lib/Neuron/Zettelkasten/Query/Error.hs
@@ -8,7 +8,7 @@
 
 import Data.Aeson
 import Neuron.Orphans ()
-import Neuron.Zettelkasten.ID (InvalidID, ZettelID, zettelIDText)
+import Neuron.Zettelkasten.ID (InvalidID, ZettelID (..))
 import Relude
 import Text.URI (URI)
 import qualified Text.URI as URI
@@ -20,8 +20,11 @@
   | 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
+-- | Error in evaluating a query
+--
+-- 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
@@ -47,4 +50,4 @@
 
 showQueryResultError :: QueryResultError -> Text
 showQueryResultError (QueryResultError_NoSuchZettel zid) =
-  "links to non-existant zettel: " <> zettelIDText zid
+  "links to non-existant zettel: " <> zettelIDRaw zid
diff --git a/src/lib/Neuron/Zettelkasten/Query/Eval.hs b/src/lib/Neuron/Zettelkasten/Query/Eval.hs
--- a/src/lib/Neuron/Zettelkasten/Query/Eval.hs
+++ b/src/lib/Neuron/Zettelkasten/Query/Eval.hs
@@ -53,7 +53,7 @@
     MonadReader [Zettel] m
   ) =>
   Zettel ->
-  m [(Maybe Connection, Zettel)]
+  m [(Connection, Zettel)]
 queryConnections Zettel {..} = do
   -- Report any query parse errors
   case zettelError of
@@ -61,21 +61,24 @@
       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
+  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 :: DSum ZettelQuery Identity -> [(Connection, Zettel)]
     getConnections = \case
-      ZettelQuery_ZettelByID _ mconn :=> Identity res ->
-        [(mconn, res)]
-      ZettelQuery_ZettelsByTag _ mconn _mview :=> Identity res ->
-        (mconn,) <$> res
+      ZettelQuery_ZettelByID _ conn :=> Identity res ->
+        [(conn, res)]
+      ZettelQuery_ZettelsByTag _ conn _mview :=> Identity res ->
+        (conn,) <$> res
       ZettelQuery_Tags _ :=> _ ->
+        mempty
+      ZettelQuery_TagZettel _ :=> _ ->
         mempty
 
 runSomeZettelQuery ::
diff --git a/src/lib/Neuron/Zettelkasten/Query/Parser.hs b/src/lib/Neuron/Zettelkasten/Query/Parser.hs
--- a/src/lib/Neuron/Zettelkasten/Query/Parser.hs
+++ b/src/lib/Neuron/Zettelkasten/Query/Parser.hs
@@ -6,16 +6,23 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE QuasiQuotes #-}
 {-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE TypeApplications #-}
 {-# LANGUAGE TypeFamilies #-}
 {-# LANGUAGE ViewPatterns #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 
-module Neuron.Zettelkasten.Query.Parser where
+module Neuron.Zettelkasten.Query.Parser
+  ( queryFromURI,
+    queryFromURILink,
+  )
+where
 
 import Control.Monad.Except
 import Data.Some
-import Data.TagTree (mkTagPattern)
+import Data.TagTree (TagNode (..), TagPattern, constructTag, mkTagPattern)
+import Neuron.Reader.Type (ZettelFormat (..))
 import Neuron.Zettelkasten.Connection
 import Neuron.Zettelkasten.ID
 import Neuron.Zettelkasten.Query.Error
@@ -23,104 +30,117 @@
 import Neuron.Zettelkasten.Zettel (ZettelQuery (..))
 import Reflex.Dom.Pandoc (URILink (..))
 import Relude
+import Text.URI (URI)
 import qualified Text.URI as URI
 import Text.URI.QQ (queryKey)
 import Text.URI.Util (getQueryParam, hasQueryFlag)
 
+-- | Parse a query if any from a Markdown link
+queryFromURILink :: MonadError QueryParseError m => URILink -> m (Maybe (Some ZettelQuery))
+queryFromURILink l@URILink {..} =
+  queryFromURI (defaultConnection l) _uriLink_uri
+  where
+    -- The default connection to use if the user has not explicitly specified
+    -- one in the query URI.
+    defaultConnection :: URILink -> Connection
+    defaultConnection URILink {..} =
+      if isNothing _uriLink_inner
+        then Folgezettel -- Autolinks
+        -- NOTE: This will need to be changed when we implement `[[foo | some text]]`
+        else OrdinaryConnection
+
 -- | 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
+queryFromURI :: MonadError QueryParseError m => Connection -> URI -> m (Maybe (Some ZettelQuery))
+queryFromURI defConn uri = do
+  let conn = fromMaybe defConn (queryConn uri)
+  liftEither . runMaybeT $ do
+    -- Non-relevant parts of the URI should be empty
+    guard $ isNothing $ URI.uriFragment uri
+    case URI.uriScheme uri of
+      -- Look for short links, eg: `<foo-bar>`
+      Nothing -> do
+        -- Detect <foo> but not </foo> or <foo/> or </foo/>
+        let shortLinkPath = do
+              guard $ URI.uriAuthority uri == Left False
+              (False, path) <- URI.uriPath uri
+              pure path
+        (URI.unRText -> path) :| [] <- hoistMaybe shortLinkPath
+        zid <-
+          hoistMaybe $
+            -- Allow raw filename (ending with ".md"). HACK: hardcoding
+            -- format, but we shouldn't.
+            getZettelID ZettelFormat_Markdown (toString path)
+              -- Before checking for direct use of ID
+              <|> rightToMaybe (parseZettelID path)
+        pure $ Some $ ZettelQuery_ZettelByID zid conn
+      Just (URI.unRText -> proto) -> do
+        guard $ proto == "z"
+        zPath <- hoistMaybe $ fmap snd (URI.uriPath uri)
+        let -- Found "z:" without a trailing slash
+            noSlash = URI.uriAuthority uri == Left False
+            -- Found "z:/" instead of "z:"
+            hasSlash = URI.uriAuthority uri == Left True
+        case zPath of
+          -- Parse z:/<id>
+          (URI.unRText -> path) :| []
+            | hasSlash -> do
+              zid <- parseQueryZettelID uri path
+              pure $ Some $ ZettelQuery_ZettelByID zid conn
+          -- Parse z:zettel/<id>
+          (URI.unRText -> "zettel") :| [URI.unRText -> path]
+            | noSlash -> do
+              zid <- parseQueryZettelID uri path
+              pure $ Some $ ZettelQuery_ZettelByID zid conn
+          -- Parse z:zettels?...
+          (URI.unRText -> "zettels") :| []
+            | noSlash -> do
+              pure $ Some $ ZettelQuery_ZettelsByTag (tagPatterns uri "tag") conn (queryView uri)
+          -- Parse z:tags?...
+          (URI.unRText -> "tags") :| []
+            | noSlash -> do
+              pure $ Some $ ZettelQuery_Tags (tagPatterns uri "filter")
+          -- Parse z:tag/foo
+          (URI.unRText -> "tag") :| (nonEmpty . fmap (TagNode . URI.unRText) -> Just tagNodes)
+            | noSlash -> do
+              pure $ Some $ ZettelQuery_TagZettel (constructTag tagNodes)
+          _ -> empty
 
-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
+parseQueryZettelID :: MonadError QueryParseError m => URI -> Text -> m ZettelID
+parseQueryZettelID uri =
+  liftEither . first (QueryParseError_InvalidID uri) . parseZettelID
+
+tagPatterns :: URI -> Text -> [TagPattern]
+tagPatterns uri k =
+  mkTagPattern <$> getParamValues uri
   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 =
+    getParamValues :: URI -> [Text]
+    getParamValues 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)
+
+queryView :: URI -> ZettelsView
+queryView uri =
+  ZettelsView linkView isGrouped
+  where
+    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
+
+queryConn :: URI -> Maybe Connection
+queryConn uri =
+  if hasQueryFlag [queryKey|cf|] uri
+    then Just OrdinaryConnection
+    else Nothing
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
@@ -25,9 +25,8 @@
 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 Data.TagTree (Tag, TagPattern (..))
+import Data.Time.DateMayTime (DateMayTime)
 import Neuron.Reader.Type
 import Neuron.Zettelkasten.Connection
 import Neuron.Zettelkasten.ID
@@ -41,9 +40,10 @@
 --
 -- 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_ZettelByID :: ZettelID -> Connection -> ZettelQuery Zettel
+  ZettelQuery_ZettelsByTag :: [TagPattern] -> Connection -> ZettelsView -> ZettelQuery [Zettel]
   ZettelQuery_Tags :: [TagPattern] -> ZettelQuery (Map Tag Natural)
+  ZettelQuery_TagZettel :: Tag -> ZettelQuery ()
 
 -- | A zettel note
 --
@@ -51,12 +51,16 @@
 data ZettelT content = Zettel
   { zettelID :: ZettelID,
     zettelFormat :: ZettelFormat,
-    -- Relative path to this zettel in the zettelkasten directory
+    -- | Relative path to this zettel in the zettelkasten directory
     zettelPath :: FilePath,
     zettelTitle :: Text,
+    -- | Whether the title was infered from the body
     zettelTitleInBody :: Bool,
     zettelTags :: [Tag],
-    zettelDay :: Maybe Day,
+    -- | Date associated with the zettel if any
+    zettelDate :: Maybe DateMayTime,
+    zettelUnlisted :: Bool,
+    -- | List of all queries in the zettel
     zettelQueries :: [Some ZettelQuery],
     zettelError :: ContentError content,
     zettelContent :: content
@@ -118,7 +122,7 @@
 
 sortZettelsReverseChronological :: [Zettel] -> [Zettel]
 sortZettelsReverseChronological =
-  sortOn (Down . zettelDay)
+  sortOn (Down . zettelDate)
 
 deriveJSONGADT ''ZettelQuery
 
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,19 +1,22 @@
 {-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TupleSections #-}
 {-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE ViewPatterns #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 {-# OPTIONS_GHC -fno-warn-orphans #-}
 
 module Neuron.Zettelkasten.Zettel.Meta
   ( Meta (..),
-    formatZettelDate,
-    parseZettelDate,
   )
 where
 
 import Data.TagTree (Tag)
-import Data.Time
+import Data.Time.DateMayTime (DateMayTime)
 import Data.YAML
 import Relude
 
@@ -22,7 +25,9 @@
   { title :: Maybe Text,
     tags :: Maybe [Tag],
     -- | Creation day
-    date :: Maybe Day
+    date :: Maybe DateMayTime,
+    -- | List in the z-index
+    unlisted :: Maybe Bool
   }
   deriving (Eq, Show, Generic)
 
@@ -32,8 +37,9 @@
       Meta
         <$> m .:? "title"
         -- "keywords" is an alias for "tags"
-        <*> (liftA2 (<|>) (m .:? "tags") (m .:? "keywords"))
+        <*> liftA2 (<|>) (m .:? "tags") (m .:? "keywords")
         <*> m .:? "date"
+        <*> m .:? "unlisted"
 
 -- NOTE: Not using this instance because it generates "tags: null" when tags is
 -- Nothing.
@@ -44,23 +50,3 @@
 --         "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
--- a/src/lib/Neuron/Zettelkasten/Zettel/Parser.hs
+++ b/src/lib/Neuron/Zettelkasten/Zettel/Parser.hs
@@ -1,13 +1,17 @@
 {-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE TupleSections #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 
 module Neuron.Zettelkasten.Zettel.Parser where
 
 import Control.Monad.Writer
+import Data.List (nub)
 import Data.Some
-import qualified Data.Text as T
+import Data.TagTree (Tag)
 import Neuron.Reader.Type
 import Neuron.Zettelkasten.ID
 import Neuron.Zettelkasten.Query.Error
@@ -29,34 +33,39 @@
 parseZettel format zreader fn zid s = do
   case zreader fn s of
     Left parseErr ->
-      Left $ Zettel zid format fn "Unknown" False [] Nothing [] parseErr s
+      Left $ Zettel zid format fn "Unknown" False [] Nothing False [] parseErr s
     Right (meta, doc) ->
-      let (title, titleInBody) = case Meta.title =<< meta of
+      let -- Determine zettel title
+          (title, titleInBody) = case Meta.title =<< meta of
             Just tit -> (tit, False)
-            Nothing -> fromMaybe ("Untitled", False) $ do
+            Nothing -> fromMaybe (zettelIDRaw zid, 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
+          -- Accumulate queries
           (queries, errors) = runWriter $ extractQueries doc
-       in Right $ Zettel zid format fn title titleInBody tags day queries errors doc
+          -- Determine zettel tags
+          metaTags = fromMaybe [] $ Meta.tags =<< meta
+          queryTags = getInlineTag `mapMaybe` queries
+          tags = nub $ metaTags <> queryTags
+          -- Determine other metadata
+          date = Meta.date =<< meta
+          unlisted = fromMaybe False $ Meta.unlisted =<< meta
+       in Right $ Zettel zid format fn title titleInBody tags date unlisted 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
+      fmap catMaybes $
+        forM (queryURILinks doc) $ \ul ->
+          case queryFromURILink ul of
+            Left e -> do
+              tell [e]
+              pure Nothing
+            Right v ->
+              pure v
+    getInlineTag :: Some ZettelQuery -> Maybe Tag
+    getInlineTag = \case
+      Some (ZettelQuery_TagZettel tag) -> Just tag
+      _ -> Nothing
 
 -- | Like `parseZettel` but operates on multiple files.
 parseZettels ::
diff --git a/src/lib/Text/URI/Util.hs b/src/lib/Text/URI/Util.hs
--- a/src/lib/Text/URI/Util.hs
+++ b/src/lib/Text/URI/Util.hs
@@ -13,18 +13,23 @@
 
 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
+  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
+  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/Data/Graph/Labelled/AlgorithmSpec.hs b/test/Data/Graph/Labelled/AlgorithmSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Data/Graph/Labelled/AlgorithmSpec.hs
@@ -0,0 +1,42 @@
+{-# LANGUAGE GeneralisedNewtypeDeriving #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Data.Graph.Labelled.AlgorithmSpec
+  ( spec,
+  )
+where
+
+import Data.Graph.Labelled
+import Relude
+import Test.Hspec
+
+newtype SimpleVertex = SimpleVertex String
+  deriving (Show, Eq, Ord, IsString)
+
+instance Vertex SimpleVertex where
+  type VertexID SimpleVertex = String
+  vertexID (SimpleVertex v) = v
+
+spec :: Spec
+spec = do
+  let mkG = mkGraphFrom @(Maybe ()) @SimpleVertex
+      e = Just ()
+  describe "cluster" $ do
+    it "detects 1-graph cluster" $ do
+      clusters (mkG ["a"] []) `shouldBe` ["a" :| []]
+    it "detects 2-graph clusters" $ do
+      sort (clusters (mkG ["a", "b"] []))
+        `shouldBe` ["a" :| [], "b" :| []]
+      sort (clusters (mkG ["a", "b"] [(e, "a", "b")]))
+        `shouldBe` ["a" :| []]
+    it "detects 2-cyclic-graph clusters" $ do
+      sort (clusters (mkG ["a", "b"] [(e, "a", "b"), (e, "b", "a")]))
+        -- Returns empty list on cyclic graphs
+        `shouldBe` []
+    it "detects 3-cyclic-graph clusters" $ do
+      sort (clusters (mkG ["a", "b", "c"] [(e, "a", "b"), (e, "b", "a")]))
+        -- a,b are not included, because they have no mothers.
+        `shouldBe` ["c" :| []]
diff --git a/test/Neuron/Config/AliasSpec.hs b/test/Neuron/Config/AliasSpec.hs
--- a/test/Neuron/Config/AliasSpec.hs
+++ b/test/Neuron/Config/AliasSpec.hs
@@ -26,4 +26,4 @@
     fmap renderAlias (parse aliasParser "<hspec>" s) `shouldBe` Right s
   where
     renderAlias Alias {..} =
-      zettelIDText aliasZettel <> ":" <> zettelIDText targetZettel
+      zettelIDSlug aliasZettel <> ":" <> zettelIDSlug targetZettel
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.7" `isGreater` olderThan
-      "0.6" `isLesserOrEqual` olderThan -- This is current version
+      "1.3" `isGreater` olderThan
+      "1.0" `isLesserOrEqual` olderThan -- This is current version
       "0.4" `isLesserOrEqual` olderThan
     it "full versions" $ do
-      "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
+      "1.2.1.2" `isGreater` olderThan
+      "1.0.15" `isGreater` olderThan
+      "1.0.12.8" `isGreater` olderThan
+      "1.0.0.0" `isLesserOrEqual` olderThan -- This is current version
+      "0.6.1.0" `isLesserOrEqual` olderThan
     it "within same major version" $ do
-      "0.6.11.8" `isGreater` olderThan
-      "0.6.0.0" `isLesserOrEqual` olderThan -- This is current version
+      "1.9.12.8" `isGreater` olderThan
+      "1.0.0.0" `isLesserOrEqual` olderThan -- This is current version
diff --git a/test/Neuron/Zettelkasten/ID/SchemeSpec.hs b/test/Neuron/Zettelkasten/ID/SchemeSpec.hs
--- a/test/Neuron/Zettelkasten/ID/SchemeSpec.hs
+++ b/test/Neuron/Zettelkasten/ID/SchemeSpec.hs
@@ -7,7 +7,6 @@
 where
 
 import qualified Data.Set as Set
-import Data.Time
 import Neuron.Zettelkasten.ID
 import Neuron.Zettelkasten.ID.Scheme
 import Relude
@@ -19,11 +18,10 @@
     let zettels =
           Set.fromList $
             fmap
-              parseZettelID
+              (either (error . show) id . parseZettelID)
               [ "ribeye-steak",
                 "2015403"
               ]
-        day = fromGregorian 2020 4 16
         nextAvail scheme = do
           v <- genVal scheme
           pure $ nextAvailableZettelID zettels v scheme
@@ -33,15 +31,7 @@
           `shouldReturn` Left IDConflict_AlreadyExists
       it "succeeds" $ do
         nextAvail (IDSchemeCustom "sunny-side-eggs")
-          `shouldReturn` Right (ZettelCustomID "sunny-side-eggs")
-    context "date ID" $ do
-      it "should return index 0" $ do
-        let otherDay = fromGregorian 2020 5 16
-        nextAvail (IDSchemeDate otherDay)
-          `shouldReturn` Right (ZettelDateID otherDay 1)
-      it "should return correct index" $
-        nextAvail (IDSchemeDate day)
-          `shouldReturn` Right (ZettelDateID day 4)
+          `shouldReturn` Right (unsafeMkZettelID "sunny-side-eggs")
     context "hash ID" $ do
       it "should succeed" $
         nextAvail IDSchemeHash
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
@@ -7,7 +7,6 @@
 where
 
 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
@@ -16,31 +15,30 @@
 spec :: Spec
 spec = do
   describe "ID parsing" $ do
-    let day = fromGregorian 2020 3 19
-    context "date id parsing" $ do
-      let zid = Z.ZettelDateID day 1
-      it "parses a zettel ID" $ do
-        Z.parseZettelID "2011401" `shouldBe` zid
-      it "parses a zettel ID from zettel filename" $ do
-        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"
+      let zid = Z.unsafeMkZettelID "20abcde"
       it "parses a custom zettel ID" $ do
-        Z.parseZettelID' "20abcde" `shouldBe` Right zid
+        Z.parseZettelID "20abcde" `shouldBe` Right zid
       it "parses a custom zettel ID from zettel filename" $ do
-        Z.getZettelID "20abcde.md" `shouldBe` Just zid
+        Z.getZettelID Z.ZettelFormat_Markdown "20abcde.md" `shouldBe` Just zid
         Z.zettelIDSourceFileName zid Z.ZettelFormat_Markdown `shouldBe` "20abcde.md"
-      let deceptiveZid = Z.ZettelCustomID "2136537e"
+      let deceptiveZid = Z.unsafeMkZettelID "2136537e"
       it "parses a custom zettel ID that looks like date ID" $ do
-        Z.parseZettelID' "2136537e" `shouldBe` Right deceptiveZid
+        Z.parseZettelID "2136537e" `shouldBe` Right deceptiveZid
+      it "parses a custom zettel ID with dot" $ do
+        Z.parseZettelID "foo.bar" `shouldBe` Right (Z.ZettelID "foo.bar" "foo.bar")
+        -- Even if there is a ".md" (not a file extension)
+        Z.parseZettelID "foo.md" `shouldBe` Right (Z.ZettelID "foo.md" "foo.bar")
+      it "parses full-phrase IDs" $ do
+        Z.parseZettelID "foo bar" `shouldBe` Right (Z.ZettelID "foo_bar" "foo bar")
+    context "i18n" $ do
+      it "deals with unicode chars" $ do
+        Z.parseZettelID "计算机" `shouldBe` Right (Z.ZettelID "计算机" "计算机")
+    context "failures" $ do
+      it "fails to parse ID with disallowed characters" $ do
+        Z.parseZettelID "/foo" `shouldSatisfy` isLeft
+        Z.parseZettelID "foo$" `shouldSatisfy` isLeft
   describe "ID converstion" $ do
     context "JSON encoding" $ do
-      let day = fromGregorian 2020 3 19
-          zid = Z.ZettelDateID day 1
       it "Converts ID to text when encoding to JSON" $ do
-        Aeson.toJSON (Z.ZettelCustomID "20abcde") `shouldBe` Aeson.String "20abcde"
-        Aeson.toJSON zid `shouldBe` Aeson.String "2011401"
-    it "Date ID to Text" $ do
-      let day = fromGregorian 2020 4 21
-      Z.zettelIDText (Z.ZettelDateID day 1) `shouldBe` "2016201"
+        Aeson.toJSON (Z.unsafeMkZettelID "20abcde") `shouldBe` Aeson.String "20abcde"
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
@@ -1,4 +1,5 @@
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RankNTypes #-}
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE NoImplicitPrelude #-}
 
@@ -7,105 +8,93 @@
   )
 where
 
+import Control.Monad.Catch (MonadThrow)
 import Data.Default (def)
-import Data.Some
-import Data.TagTree
-import Neuron.Zettelkasten.Connection
-import Neuron.Zettelkasten.ID
-import Neuron.Zettelkasten.Query.Parser
-import Neuron.Zettelkasten.Query.Theme
-import Neuron.Zettelkasten.Zettel
-import Reflex.Dom.Pandoc.URILink
+import Data.Some (Some (Some))
+import Data.TagTree (Tag (Tag), mkTagPattern)
+import qualified Network.URI.Encode as E
+import Neuron.Zettelkasten.Connection (Connection (..))
+import Neuron.Zettelkasten.ID (unsafeMkZettelID)
+import Neuron.Zettelkasten.Query.Parser (queryFromURILink)
+import Neuron.Zettelkasten.Zettel (ZettelQuery (..))
+import Reflex.Dom.Pandoc.URILink (URILink (URILink))
 import Relude
 import Test.Hspec
-import Text.URI
+import Text.Pandoc.Definition (Inline (Str))
+import Text.URI (URI, mkURI)
 
 spec :: Spec
 spec = do
-  legacyLinks
-  shortLinks
-
-legacyLinks :: Spec
-legacyLinks = do
-  describe "Parse zettels by tag URIs" $ do
-    for_ [("zquery", Nothing), ("zcfquery", Just OrdinaryConnection)] $ \(scheme, mconn) -> do
-      context scheme $ do
-        let zettelsByTag pat mview =
-              Right $ Just $ Some $ ZettelQuery_ZettelsByTag (fmap mkTagPattern pat) mconn mview
-            withScheme s = toText scheme <> s
-            legacyLink l = mkURILink "." l
-        it "Parse all zettels URI" $ do
-          queryFromURILink (legacyLink $ withScheme "://search")
-            `shouldBe` zettelsByTag [] def
-        it "Parse single tag" $
-          queryFromURILink (legacyLink $ withScheme "://search?tag=foo")
-            `shouldBe` zettelsByTag ["foo"] def
-        it "Parse hierarchical tag" $ do
-          queryFromURILink (legacyLink $ withScheme "://search?tag=foo/bar")
-            `shouldBe` zettelsByTag ["foo/bar"] def
-        it "Parse tag pattern" $ do
-          queryFromURILink (legacyLink $ withScheme "://search?tag=foo/**/bar/*/baz")
-            `shouldBe` zettelsByTag ["foo/**/bar/*/baz"] def
-        it "Parse multiple tags" $
-          queryFromURILink (legacyLink $ withScheme "://search?tag=foo&tag=bar")
-            `shouldBe` zettelsByTag ["foo", "bar"] def
-        it "Handles ?grouped" $ do
-          queryFromURILink (legacyLink $ withScheme "://search?grouped")
-            `shouldBe` zettelsByTag [] (ZettelsView def True)
-        it "Handles ?linkTheme=withDate" $ do
-          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:/" $
-      queryFromURILink (mkURILink "1234567" "z:/")
-        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID zid Nothing)
-    it "parses z:/ ignoring annotation" $
-      queryFromURILink (mkURILink "1234567" "z://foo-bar")
-        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID zid Nothing)
-    it "parses zcf:/" $
-      queryFromURILink (mkURILink "1234567" "zcf:/")
-        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID zid (Just OrdinaryConnection))
-  describe "Parse tags URI" $ do
-    it "parses zquery://tags" $
-      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 = mkURILink s s
-    it "parses date ID" $ do
-      queryFromURILink (shortLink "1234567")
-        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (parseZettelID "1234567") Nothing)
     it "parses custom/hash ID" $ do
       queryFromURILink (shortLink "foo-bar")
-        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (parseZettelID "foo-bar") Nothing)
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (unsafeMkZettelID "foo-bar") Folgezettel)
     it "even with ?cf" $ do
       queryFromURILink (shortLink "foo-bar?cf")
-        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (parseZettelID "foo-bar") (Just OrdinaryConnection))
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (unsafeMkZettelID "foo-bar") OrdinaryConnection)
     it "parses prefixed short link" $ do
       queryFromURILink (shortLink "z:/foo-bar")
-        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (parseZettelID "foo-bar") Nothing)
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (unsafeMkZettelID "foo-bar") Folgezettel)
     it "resolves ambiguity using absolute URI" $ do
       queryFromURILink (shortLink "z:/tags")
-        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (parseZettelID "tags") Nothing)
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (unsafeMkZettelID "tags") Folgezettel)
     it "z:zettels" $ do
       queryFromURILink (shortLink "z:zettels")
-        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelsByTag [] Nothing def)
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelsByTag [] Folgezettel def)
     it "z:zettels?tag=foo" $ do
       queryFromURILink (shortLink "z:zettels?tag=foo")
-        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelsByTag [mkTagPattern "foo"] Nothing def)
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelsByTag [mkTagPattern "foo"] Folgezettel def)
     it "z:zettels?cf" $ do
       queryFromURILink (shortLink "z:zettels?cf")
-        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelsByTag [] (Just OrdinaryConnection) def)
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelsByTag [] OrdinaryConnection def)
     it "z:tags" $ do
       queryFromURILink (shortLink "z:tags")
         `shouldBe` Right (Just $ Some $ ZettelQuery_Tags [])
     it "z:tags?filter=foo" $ do
       queryFromURILink (shortLink "z:tags?filter=foo")
         `shouldBe` Right (Just $ Some $ ZettelQuery_Tags [mkTagPattern "foo"])
+    it "z:tag/foo" $ do
+      queryFromURILink (shortLink "z:tag/foo")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_TagZettel (Tag "foo"))
+    it "z:tag/foo/bar/baz" $ do
+      queryFromURILink (shortLink "z:tag/foo/bar/baz")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_TagZettel (Tag "foo/bar/baz"))
+    it "i18n" $ do
+      let encodeUriPath = toText . E.encode . toString
+          shortLinkUnicode s = mkURILink' (mkURI . encodeUriPath) s s
+      queryFromURILink (shortLinkUnicode "计算机")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (unsafeMkZettelID "计算机") Folgezettel)
+  let normalLink = mkURILink "some link text"
+  describe "flexible links (regular markdown)" $ do
+    it "Default connection type should be cf" $ do
+      queryFromURILink (normalLink "foo-bar")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (unsafeMkZettelID "foo-bar") OrdinaryConnection)
+    it "Supports full filename instead of zettel ID" $ do
+      queryFromURILink (normalLink "foo-bar.md")
+        `shouldBe` Right (Just $ Some $ ZettelQuery_ZettelByID (unsafeMkZettelID "foo-bar") OrdinaryConnection)
+  describe "non-connection links" $ do
+    it "pass through normal links" $ do
+      queryFromURILink (normalLink "https://www.srid.ca")
+        `shouldBe` Right Nothing
+      queryFromURILink (normalLink "/static/resume.pdf")
+        `shouldBe` Right Nothing
+      queryFromURILink (normalLink "/static/")
+        `shouldBe` Right Nothing
+      queryFromURILink (normalLink "/static")
+        `shouldBe` Right Nothing
 
 mkURILink :: Text -> Text -> URILink
-mkURILink s l =
-  URILink s $ either (error . toText . displayException) id $ mkURI l
+mkURILink =
+  mkURILink' mkURI
+
+mkURILink' ::
+  (forall m. MonadThrow m => Text -> m URI) ->
+  Text ->
+  Text ->
+  URILink
+mkURILink' mk linkText s =
+  -- TODO: Do this in reflex-dom-pandoc
+  let uri = either (error . toText . displayException) id $ mk s
+      inner = if linkText == s then Nothing else Just [Str linkText]
+   in URILink inner uri
diff --git a/test/Neuron/Zettelkasten/Zettel/ParserSpec.hs b/test/Neuron/Zettelkasten/Zettel/ParserSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Neuron/Zettelkasten/Zettel/ParserSpec.hs
@@ -0,0 +1,37 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+module Neuron.Zettelkasten.Zettel.ParserSpec
+  ( spec,
+  )
+where
+
+import Data.TagTree (Tag (Tag))
+import Neuron.Reader.Markdown (parseMarkdown)
+import Neuron.Reader.Type (ZettelFormat (ZettelFormat_Markdown))
+import Neuron.Zettelkasten.ID (unsafeMkZettelID)
+import Neuron.Zettelkasten.Zettel
+  ( Zettel,
+    ZettelT (zettelTags),
+    sansContent,
+  )
+import Neuron.Zettelkasten.Zettel.Parser (parseZettel)
+import Relude
+import Test.Hspec
+
+spec :: Spec
+spec = do
+  describe "inline tags" $ do
+    let parseSomeZettel =
+          sansContent . parseZettel ZettelFormat_Markdown parseMarkdown "<test>" (unsafeMkZettelID "note.md")
+    it "simple" $ do
+      let z :: Zettel = parseSomeZettel "An #inline tag"
+      zettelTags z `shouldBe` [Tag "inline"]
+    it "hierarchical" $ do
+      let z :: Zettel = parseSomeZettel "An #foo/bar/baz tag"
+      zettelTags z `shouldBe` [Tag "foo/bar/baz"]
+    it "allows URLs with a hash" $ do
+      pendingWith "#397"
+      let z :: Zettel = parseSomeZettel "Some http://www.google.com/#foo url"
+      zettelTags z `shouldBe` []
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
@@ -9,11 +9,13 @@
 
 import Data.TagTree
 import Data.Time.Calendar
+import Data.Time.DateMayTime
+import Data.Time.LocalTime
 import Neuron.Reader.Markdown
 import Neuron.Reader.Type
 import Neuron.Zettelkasten.ID
 import Neuron.Zettelkasten.Zettel
-import Neuron.Zettelkasten.Zettel.Meta (Meta)
+import Neuron.Zettelkasten.Zettel.Meta
 import Relude
 import Test.Hspec
 
@@ -24,20 +26,48 @@
       noContent = MetadataOnly ()
   describe "sortZettelsReverseChronological" $ do
     let mkDay = fromGregorian 2020 3
+        mkZettelDay n =
+          Just $ mkDateMayTime $ Left (mkDay n)
+        mkZettelLocalTime day hh mm =
+          Just $ mkDateMayTime $ Right $ LocalTime (mkDay day) (TimeOfDay hh mm 0)
+
         (_ :: Maybe Meta, _dummyContent) = either (error . show) id $ parseMarkdown "<spec>" "Dummy"
-        mkZettel day idx =
+
+        mkZettel zid datetime =
           Zettel
-            (ZettelDateID (mkDay day) idx)
+            (unsafeMkZettelID zid)
             ZettelFormat_Markdown
             "<spec>.md"
             "Some title"
             False
             [Tag "science", Tag "journal/class"]
-            (Just $ mkDay day)
+            datetime
+            False
             noQueries
             noError
             noContent
-    it "sorts correctly" $ do
-      let zs = [mkZettel 3 2, mkZettel 5 1]
+
+    it "sorts correctly with day" $ do
+      let zs =
+            [ mkZettel "a" (mkZettelDay 3),
+              mkZettel "b" (mkZettelDay 5)
+            ]
       sortZettelsReverseChronological zs
-        `shouldBe` [mkZettel 5 1, mkZettel 3 2]
+        `shouldBe` reverse zs
+
+    it "sorts correctly with localtime" $ do
+      let zs =
+            [ mkZettel "a" (mkZettelLocalTime 3 9 59),
+              mkZettel "b" (mkZettelLocalTime 3 10 0)
+            ]
+      sortZettelsReverseChronological zs
+        `shouldBe` reverse zs
+
+    it "sorts correctly with mixed dates" $ do
+      let zs =
+            [ mkZettel "c" (mkZettelLocalTime 7 0 0),
+              mkZettel "a" (mkZettelDay 5),
+              mkZettel "b" (mkZettelLocalTime 3 0 0)
+            ]
+      sortZettelsReverseChronological zs
+        `shouldBe` zs
