@). If a future
+legitimate use of a colon-bearing tag arises, an allow-list belongs here.
+-}
+nodeSplices :: X.Node -> [UnboundSplice]
+nodeSplices = \case
+ X.Element name attrs children ->
+ [SpliceElement name | T.any (== ':') name]
+ <> concatMap attrSplices attrs
+ <> foldMap nodeSplices children
+ _ -> []
+
+attrSplices :: (Text, Text) -> [UnboundSplice]
+attrSplices (_, value) = SpliceAttribute <$> attrSpliceRefs value
+
+{- | Extract the names from any @${name}@ tokens in a string. A bare @${@
+with no closing brace (or one that wraps a nested @${@, e.g.
+@${incomplete ${valid}@) is treated as literal text — we step past it and
+keep scanning, so a real splice that follows is still reported on its own.
+-}
+attrSpliceRefs :: Text -> [Text]
+attrSpliceRefs t = case T.breakOn "${" t of
+ (_, "") -> []
+ (_, rest) ->
+ let body = T.drop 2 rest
+ in case T.breakOn "}" body of
+ (name, suffix)
+ | "}" `T.isPrefixOf` suffix
+ , not (T.any (== '$') name) ->
+ name : attrSpliceRefs (T.drop 1 suffix)
+ _ -> attrSpliceRefs body
diff --git a/emanote/src/Emanote/View/Template.hs b/emanote/src/Emanote/View/Template.hs
index 4d126f989..3dca96b65 100644
--- a/emanote/src/Emanote/View/Template.hs
+++ b/emanote/src/Emanote/View/Template.hs
@@ -1,6 +1,6 @@
module Emanote.View.Template (emanoteSiteOutput, render) where
-import Control.Monad.Logger (MonadLoggerIO)
+import Control.Monad.Logger (MonadLogger, MonadLoggerIO)
import Data.Aeson.Types qualified as Aeson
import Data.Map.Strict qualified as Map
import Data.Map.Syntax ((##))
@@ -19,6 +19,7 @@ import Emanote.Model.Note qualified as MN
import Emanote.Model.SData qualified as SData
import Emanote.Model.Stork (renderStorkIndex)
import Emanote.Model.Toc (newToc, renderToc, tocUnnecessaryToRender)
+import Emanote.Prelude (logW)
import Emanote.Route qualified as R
import Emanote.Route.SiteRoute (SiteRoute)
import Emanote.Route.SiteRoute qualified as SR
@@ -26,8 +27,10 @@ import Emanote.Route.SiteRoute.Class (indexRoute)
import Emanote.View.Common qualified as C
import Emanote.View.Export (renderExport)
import Emanote.View.Feed (feedDiscoveryLink, renderFeed)
+import Emanote.View.LintTemplate (UnboundSplice, formatWarning, scanRenderedHtml)
import Emanote.View.TagIndex qualified as TagIndex
import Emanote.View.TaskIndex qualified as TaskIndex
+import GHC.IO.Unsafe (unsafePerformIO)
import Heist qualified as H
import Heist.Extra.Splices.List qualified as Splices
import Heist.Extra.Splices.Pandoc qualified as Splices
@@ -45,7 +48,9 @@ import Text.Pandoc.Definition (Pandoc (..))
emanoteSiteOutput :: (MonadIO m, MonadLoggerIO m) => Prism' FilePath SiteRoute -> ModelEma -> SR.SiteRoute -> m (Ema.Asset LByteString)
emanoteSiteOutput rp model' r = do
let model = M.withRoutePrism rp model'
- render model r <&> fmap fixStaticUrl
+ asset <- render model r <&> fmap fixStaticUrl
+ warnUnboundSplices (SR.siteRouteUrl model r) asset
+ pure asset
where
-- See the FIXME in more-head.tpl.
fixStaticUrl :: LByteString -> LByteString
@@ -67,6 +72,37 @@ emanoteSiteOutput rp model' r = do
guard $ not $ T.null prefix
pure prefix
+{- | See 'Emanote.View.LintTemplate.scanRenderedHtml' for the scanning
+rationale. This wrapper owns dedup (per @(route, splice)@) and log
+delivery so the lint module stays a pure scanner.
+-}
+warnUnboundSplices :: (MonadIO m, MonadLogger m) => Text -> Ema.Asset LByteString -> m ()
+warnUnboundSplices routeUrl = \case
+ Ema.AssetGenerated Ema.Html bytes ->
+ case scanRenderedHtml (toString routeUrl) (toStrict bytes) of
+ Left err -> warn $ "lint parse failed: " <> err
+ Right warnings -> do
+ fresh <- liftIO $ atomicModifyIORef' lintWarningCache $ \seen ->
+ let entries = Set.fromList ((routeUrl,) <$> warnings)
+ new = Set.difference entries seen
+ in (Set.union seen entries, snd <$> Set.toAscList new)
+ forM_ fresh $ warn . ("unbound splice " <>) . formatWarning
+ -- Static files and Atom/JSON assets bypass the Heist render path, so
+ -- there is no template-substitution surface to lint here.
+ _ -> pass
+ where
+ warn detail = logW $ "Template lint on '" <> routeUrl <> "': " <> detail
+
+{- | Process-wide cache of @(route, splice)@ pairs already logged. Lives at the
+rendering orchestration layer rather than inside 'Emanote.View.LintTemplate'
+so the lint module stays a pure scanner. Bounded in practice by (number of
+rendered routes) × (distinct splice typos in the user's templates), which is
+small for any reasonable site — there is no eviction.
+-}
+{-# NOINLINE lintWarningCache #-}
+lintWarningCache :: IORef (Set (Text, UnboundSplice))
+lintWarningCache = unsafePerformIO (newIORef mempty)
+
render :: (MonadIO m, MonadLoggerIO m) => Model -> SR.SiteRoute -> m (Ema.Asset LByteString)
render m = \case
SR.SiteRoute_MissingR urlPath -> do
diff --git a/emanote/test/Emanote/View/LintTemplateSpec.hs b/emanote/test/Emanote/View/LintTemplateSpec.hs
new file mode 100644
index 000000000..50f29c4e9
--- /dev/null
+++ b/emanote/test/Emanote/View/LintTemplateSpec.hs
@@ -0,0 +1,58 @@
+module Emanote.View.LintTemplateSpec where
+
+import Emanote.View.LintTemplate (UnboundSplice (..), formatWarning, scanRenderedHtml)
+import Relude
+import Test.Hspec
+
+spec :: Spec
+spec = do
+ describe "scanRenderedHtml" $ do
+ it "ignores well-rendered HTML with no splice survivors" $ do
+ scanRenderedHtml "ok.html" "hello
"
+ `shouldBe` Right []
+
+ it "reports an element whose tag name still has a Heist-style colon" $ do
+ scanRenderedHtml "ok.html" ""
+ `shouldBe` Right [SpliceElement "ema:tite"]
+
+ it "reports nested unbound element splices" $ do
+ scanRenderedHtml "ok.html" "x
"
+ `shouldBe` Right [SpliceElement "ema:foo"]
+
+ it "reports a literal ${name} that survived as an attribute value" $ do
+ scanRenderedHtml "ok.html" "x"
+ `shouldBe` Right [SpliceAttribute "value:siteUrl"]
+
+ it "reports multiple ${...} references in one attribute" $ do
+ scanRenderedHtml "ok.html" "
"
+ `shouldBe` Right [SpliceAttribute "a", SpliceAttribute "b"]
+
+ it "deduplicates repeated occurrences across the document" $ do
+ scanRenderedHtml "ok.html" ""
+ `shouldBe` Right [SpliceElement "ema:foo"]
+
+ it "leaves text nodes alone (e.g. ${...} appearing inside a )" $ do
+ scanRenderedHtml "ok.html" "${not-an-attr}"
+ `shouldBe` Right []
+
+ it "still reports a valid ${...} that follows an unbalanced ${" $ do
+ scanRenderedHtml "ok.html" "x"
+ `shouldBe` Right [SpliceAttribute "valid"]
+
+ it "ignores a trailing ${ with no closing brace" $ do
+ scanRenderedHtml "ok.html" "x"
+ `shouldBe` Right [SpliceAttribute "valid"]
+
+ it "skips the parse entirely when the bytes have no splice marker" $ do
+ -- The pre-check short-circuits to Right [] without invoking parseHTML,
+ -- so even malformed HTML returns clean — there is nothing for the lint
+ -- to find when the bytes contain neither '${' nor a colon-tag.
+ scanRenderedHtml "bad.html" ""
+ `shouldBe` Right []
+
+ describe "formatWarning" $ do
+ it "formats element warnings as a tag" $ do
+ formatWarning (SpliceElement "ema:tite") `shouldBe` ""
+
+ it "formats attribute warnings as a dollar-brace token" $ do
+ formatWarning (SpliceAttribute "value:siteUrl") `shouldBe` "${value:siteUrl}"