From 2a39f5bfc6fa7aae575d1704377281ae3e19c55c Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Wed, 6 May 2026 21:03:42 -0400 Subject: [PATCH 1/9] Warn about unbound Heist template splices (closes #81) Heist's interpreted runNode leaves unknown elements verbatim and getAttributeSplice falls back to the literal "${name}" text when 'name' is unbound. Both paths failed silently. Add Emanote.View.LintTemplate, scan each rendered HTML asset post-render inside emanoteSiteOutput, and log one warning per fresh (route, splice) pair via logW. A process-wide IORef de-dupes across live-server reloads. --- docs/guide/html-template.md | 4 + emanote/CHANGELOG.md | 1 + emanote/emanote.cabal | 2 + emanote/src/Emanote/View/LintTemplate.hs | 105 ++++++++++++++++++ emanote/src/Emanote/View/Template.hs | 5 +- emanote/test/Emanote/View/LintTemplateSpec.hs | 43 +++++++ 6 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 emanote/src/Emanote/View/LintTemplate.hs create mode 100644 emanote/test/Emanote/View/LintTemplateSpec.hs diff --git a/docs/guide/html-template.md b/docs/guide/html-template.md index 117660a99..2e8ac721c 100644 --- a/docs/guide/html-template.md +++ b/docs/guide/html-template.md @@ -17,3 +17,7 @@ Emanote includes a default layout that includes a [[sidebar]], but can be custom ```query children:. ``` + +## Diagnosing typos + +Heist renders unknown splice tags and `${...}` references as literal text in the page, so a typo like `` or `${value:sitURL}` used to fail silently. Emanote now scans every rendered route for those leftovers and logs a warning per typo per route — once per process, so a live-server reload does not redo the noise. Watch the `emanote run` console (or the `emanote gen` log) for a `Warn` line and grep your `.tpl` files for the reported tag. diff --git a/emanote/CHANGELOG.md b/emanote/CHANGELOG.md index 184f29a0b..2c66a26cf 100644 --- a/emanote/CHANGELOG.md +++ b/emanote/CHANGELOG.md @@ -25,6 +25,7 @@ **Bug fixes** +- Heist templates: a typo in a splice tag (e.g. `` instead of ``) or in an attribute reference (e.g. `${value:sitURL}`) used to render through silently, with the malformed tag or `${...}` token leaking into the page. Each rendered route is now scanned post-render and a per-route warning is logged for every surviving splice reference (closes [#81](https://github.com/srid/emanote/issues/81)). - Timeline heatmap backlinks now use the daily note's route-derived date instead of parsing `YYYY-MM-DD` out of the rendered title, so custom-titled daily notes still appear in the heatmap. - Cascade-declared `tags` no longer disappear from a child note that declares any of its own. The metadata-cascade merger previously inherited `aeson-extra`'s `lodashMerge`, which aligns arrays by index — `tags: [team-doc]` in `folder.yaml` plus `tags: [internal-note]` in `folder/note.md` produced `[internal-note]`, dropping the cascaded entry both from the per-page chip strip and from the `#352` global tag index. The merger now unions cascade arrays; see [yaml-config](docs/guide/yaml-config.md) for the full contract. `aeson-extra` is no longer a dependency (closes [#697](https://github.com/srid/emanote/issues/697)). - Wiki link custom titles now render HTML entities like ` ` the same way regular Markdown link labels do. Previously `[[note|Spivak (2014)]]` rendered the entity text literally as ` ` (closes [#441](https://github.com/srid/emanote/issues/441)). diff --git a/emanote/emanote.cabal b/emanote/emanote.cabal index f039d875c..8b1bea6fb 100644 --- a/emanote/emanote.cabal +++ b/emanote/emanote.cabal @@ -218,6 +218,7 @@ library Emanote.View.Feed Emanote.View.I18n Emanote.View.JsBundle + Emanote.View.LintTemplate Emanote.View.LiveServerFiles Emanote.View.StaticUrl Emanote.View.TagIndex @@ -268,3 +269,4 @@ test-suite test Emanote.Source.IgnoreSpec Emanote.View.FeedSpec Emanote.View.I18nSpec + Emanote.View.LintTemplateSpec diff --git a/emanote/src/Emanote/View/LintTemplate.hs b/emanote/src/Emanote/View/LintTemplate.hs new file mode 100644 index 000000000..6d1348074 --- /dev/null +++ b/emanote/src/Emanote/View/LintTemplate.hs @@ -0,0 +1,105 @@ +{- | Surface warnings when a Heist template references a splice that has no +binding. Closes . + +Emanote uses interpreted Heist, where 'Heist.Interpreted.runNode' leaves an +element verbatim when its name has no binding, and 'getAttributeSplice' +falls back to the literal @${name}@ text when @name@ has no binding. Both +paths fail silently in the rendered HTML. We re-parse the rendered output +and warn on either signal. +-} +module Emanote.View.LintTemplate ( + warnUnboundSplices, + + -- * Internals (exposed for testing) + UnboundSplice (..), + scanRenderedHtml, + formatWarning, +) where + +import Control.Monad.Logger (MonadLogger) +import Data.Set qualified as Set +import Data.Text qualified as T +import Ema qualified +import Emanote.Prelude (logW) +import GHC.IO.Unsafe (unsafePerformIO) +import Relude +import Text.XmlHtml qualified as X + +-- | An unbound splice reference detected in rendered HTML output. +data UnboundSplice + = SpliceElement Text + | SpliceAttribute Text + deriving stock (Eq, Ord, Show) + +-- | Render a warning as user-facing text. +formatWarning :: UnboundSplice -> Text +formatWarning = \case + SpliceElement nm -> "<" <> nm <> "/>" + SpliceAttribute nm -> "${" <> nm <> "}" + +{- | Inspect a rendered HTML asset for unbound splice references; log one +warning per fresh @(route, splice)@ pair via 'logW'. +-} +warnUnboundSplices :: + (MonadIO m, MonadLogger m) => + -- | URL of the route whose asset we are linting (used as a label in logs + -- and as the dedup key). + Text -> + Ema.Asset LByteString -> + m () +warnUnboundSplices routeUrl = \case + Ema.AssetGenerated Ema.Html bytes -> do + let warnings = scanRenderedHtml (toString routeUrl) (toStrict bytes) + fresh <- recordOnce routeUrl warnings + forM_ fresh $ \w -> + logW $ "Unbound template splice on '" <> routeUrl <> "': " <> formatWarning w + _ -> pass + +{- | Re-parse rendered HTML and collect every unbound splice reference. Returns +a deduplicated, sorted list. An unparseable document yields no warnings — +broken HTML is a separate concern. +-} +scanRenderedHtml :: FilePath -> ByteString -> [UnboundSplice] +scanRenderedHtml fp bs = case X.parseHTML fp bs of + Right (X.HtmlDocument _ _ nodes) -> Set.toAscList (foldMap nodeSplices nodes) + _ -> [] + +nodeSplices :: X.Node -> Set UnboundSplice +nodeSplices = \case + X.Element name attrs children -> + elementSplice name + <> foldMap attrSplices attrs + <> foldMap nodeSplices children + _ -> mempty + +elementSplice :: Text -> Set UnboundSplice +elementSplice name + | T.any (== ':') name = one (SpliceElement name) + | otherwise = mempty + +attrSplices :: (Text, Text) -> Set UnboundSplice +attrSplices (_, value) = Set.fromList (SpliceAttribute <$> attrSpliceRefs value) + +-- | Extract the names from any @${name}@ tokens in a string. +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 -> name : attrSpliceRefs (T.drop 1 suffix) + | otherwise -> [] + +{- | Process-wide dedup so live-server re-renders do not log the same warning +on every refresh. +-} +{-# NOINLINE alreadyLogged #-} +alreadyLogged :: IORef (Set (Text, UnboundSplice)) +alreadyLogged = unsafePerformIO (newIORef mempty) + +recordOnce :: (MonadIO m) => Text -> [UnboundSplice] -> m [UnboundSplice] +recordOnce routeUrl ws = liftIO $ atomicModifyIORef' alreadyLogged $ \seen -> + let entries = Set.fromList ((routeUrl,) <$> ws) + fresh = Set.difference entries seen + in (Set.union seen entries, snd <$> Set.toAscList fresh) diff --git a/emanote/src/Emanote/View/Template.hs b/emanote/src/Emanote/View/Template.hs index 4d126f989..15f65db9f 100644 --- a/emanote/src/Emanote/View/Template.hs +++ b/emanote/src/Emanote/View/Template.hs @@ -26,6 +26,7 @@ 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 qualified as Lint import Emanote.View.TagIndex qualified as TagIndex import Emanote.View.TaskIndex qualified as TaskIndex import Heist qualified as H @@ -45,7 +46,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 + Lint.warnUnboundSplices (SR.siteRouteUrl model r) asset + pure asset where -- See the FIXME in more-head.tpl. fixStaticUrl :: LByteString -> LByteString diff --git a/emanote/test/Emanote/View/LintTemplateSpec.hs b/emanote/test/Emanote/View/LintTemplateSpec.hs new file mode 100644 index 000000000..3327b9c1f --- /dev/null +++ b/emanote/test/Emanote/View/LintTemplateSpec.hs @@ -0,0 +1,43 @@ +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` [] + + it "reports an element whose tag name still has a Heist-style colon" $ do + scanRenderedHtml "ok.html" "" + `shouldBe` [SpliceElement "ema:tite"] + + it "reports nested unbound element splices" $ do + scanRenderedHtml "ok.html" "
x
" + `shouldBe` [SpliceElement "ema:foo"] + + it "reports a literal ${name} that survived as an attribute value" $ do + scanRenderedHtml "ok.html" "x" + `shouldBe` [SpliceAttribute "value:siteUrl"] + + it "reports multiple ${...} references in one attribute" $ do + scanRenderedHtml "ok.html" "\"${a}" + `shouldBe` [SpliceAttribute "a", SpliceAttribute "b"] + + it "deduplicates repeated occurrences across the document" $ do + scanRenderedHtml "ok.html" "" + `shouldBe` [SpliceElement "ema:foo"] + + it "leaves text nodes alone (e.g. ${...} appearing inside a
)" $ do
+      scanRenderedHtml "ok.html" "
${not-an-attr}
" + `shouldBe` [] + + 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}" From 0f7608d6011cb62a34a12b1e017cce0417359d25 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Wed, 6 May 2026 21:12:26 -0400 Subject: [PATCH 2/9] refactor(hickey): decomplect lint scanner from delivery and dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move warnUnboundSplices, the dedup IORef, and the logging side-effect out of Emanote.View.LintTemplate and into Emanote.View.Template (the rendering orchestration layer). LintTemplate is now a pure scanner — inputs are bytes, outputs are [UnboundSplice]. Whether to log, batch, push to a UI banner, or dedupe across renders is the caller's call. --- emanote/src/Emanote/View/LintTemplate.hs | 46 +++--------------------- emanote/src/Emanote/View/Template.hs | 31 ++++++++++++++-- 2 files changed, 33 insertions(+), 44 deletions(-) diff --git a/emanote/src/Emanote/View/LintTemplate.hs b/emanote/src/Emanote/View/LintTemplate.hs index 6d1348074..7d43e8f3a 100644 --- a/emanote/src/Emanote/View/LintTemplate.hs +++ b/emanote/src/Emanote/View/LintTemplate.hs @@ -1,27 +1,22 @@ -{- | Surface warnings when a Heist template references a splice that has no -binding. Closes . +{- | Detect Heist template splices that have no binding. Pure: this module +returns the warnings; deciding what to /do/ with them (log, dedupe across +re-renders, surface in a UI banner) is the caller's job. Emanote uses interpreted Heist, where 'Heist.Interpreted.runNode' leaves an element verbatim when its name has no binding, and 'getAttributeSplice' falls back to the literal @${name}@ text when @name@ has no binding. Both paths fail silently in the rendered HTML. We re-parse the rendered output -and warn on either signal. +and report either signal as an 'UnboundSplice'. Closes +. -} module Emanote.View.LintTemplate ( - warnUnboundSplices, - - -- * Internals (exposed for testing) UnboundSplice (..), scanRenderedHtml, formatWarning, ) where -import Control.Monad.Logger (MonadLogger) import Data.Set qualified as Set import Data.Text qualified as T -import Ema qualified -import Emanote.Prelude (logW) -import GHC.IO.Unsafe (unsafePerformIO) import Relude import Text.XmlHtml qualified as X @@ -37,24 +32,6 @@ formatWarning = \case SpliceElement nm -> "<" <> nm <> "/>" SpliceAttribute nm -> "${" <> nm <> "}" -{- | Inspect a rendered HTML asset for unbound splice references; log one -warning per fresh @(route, splice)@ pair via 'logW'. --} -warnUnboundSplices :: - (MonadIO m, MonadLogger m) => - -- | URL of the route whose asset we are linting (used as a label in logs - -- and as the dedup key). - Text -> - Ema.Asset LByteString -> - m () -warnUnboundSplices routeUrl = \case - Ema.AssetGenerated Ema.Html bytes -> do - let warnings = scanRenderedHtml (toString routeUrl) (toStrict bytes) - fresh <- recordOnce routeUrl warnings - forM_ fresh $ \w -> - logW $ "Unbound template splice on '" <> routeUrl <> "': " <> formatWarning w - _ -> pass - {- | Re-parse rendered HTML and collect every unbound splice reference. Returns a deduplicated, sorted list. An unparseable document yields no warnings — broken HTML is a separate concern. @@ -90,16 +67,3 @@ attrSpliceRefs t = case T.breakOn "${" t of (name, suffix) | "}" `T.isPrefixOf` suffix -> name : attrSpliceRefs (T.drop 1 suffix) | otherwise -> [] - -{- | Process-wide dedup so live-server re-renders do not log the same warning -on every refresh. --} -{-# NOINLINE alreadyLogged #-} -alreadyLogged :: IORef (Set (Text, UnboundSplice)) -alreadyLogged = unsafePerformIO (newIORef mempty) - -recordOnce :: (MonadIO m) => Text -> [UnboundSplice] -> m [UnboundSplice] -recordOnce routeUrl ws = liftIO $ atomicModifyIORef' alreadyLogged $ \seen -> - let entries = Set.fromList ((routeUrl,) <$> ws) - fresh = Set.difference entries seen - in (Set.union seen entries, snd <$> Set.toAscList fresh) diff --git a/emanote/src/Emanote/View/Template.hs b/emanote/src/Emanote/View/Template.hs index 15f65db9f..ee96b40f1 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,9 +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 qualified as Lint +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 @@ -47,7 +49,7 @@ emanoteSiteOutput :: (MonadIO m, MonadLoggerIO m) => Prism' FilePath SiteRoute - emanoteSiteOutput rp model' r = do let model = M.withRoutePrism rp model' asset <- render model r <&> fmap fixStaticUrl - Lint.warnUnboundSplices (SR.siteRouteUrl model r) asset + warnUnboundSplices (SR.siteRouteUrl model r) asset pure asset where -- See the FIXME in more-head.tpl. @@ -70,6 +72,29 @@ emanoteSiteOutput rp model' r = do guard $ not $ T.null prefix pure prefix +{- | Scan the rendered HTML for unbound Heist splice references and log a +warning for each fresh @(route, splice)@ pair. +-} +warnUnboundSplices :: (MonadIO m, MonadLogger m) => Text -> Ema.Asset LByteString -> m () +warnUnboundSplices routeUrl = \case + Ema.AssetGenerated Ema.Html bytes -> do + let warnings = scanRenderedHtml (toString routeUrl) (toStrict bytes) + 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 $ \w -> + logW $ "Unbound template splice on '" <> routeUrl <> "': " <> formatWarning w + _ -> pass + +{- | 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. +-} +{-# 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 From c41aff78cb02c82f87b4b2b60afa1fe76b7ed4f8 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Wed, 6 May 2026 21:13:59 -0400 Subject: [PATCH 3/9] refactor(hickey): keep scanning past unbalanced \${ in attrSpliceRefs Before: an attribute value like "\${incomplete \${valid}" reported a single mangled name "incomplete \${valid"; "\${valid} \${trailing" silently dropped the trailing token. Mirrors Heist's "unbalanced \${ is literal text" substitution behavior so a real \${name} that follows a malformed token is still surfaced on its own. --- emanote/src/Emanote/View/LintTemplate.hs | 12 +++++++++--- emanote/test/Emanote/View/LintTemplateSpec.hs | 8 ++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/emanote/src/Emanote/View/LintTemplate.hs b/emanote/src/Emanote/View/LintTemplate.hs index 7d43e8f3a..e9fc611b4 100644 --- a/emanote/src/Emanote/View/LintTemplate.hs +++ b/emanote/src/Emanote/View/LintTemplate.hs @@ -57,7 +57,11 @@ elementSplice name attrSplices :: (Text, Text) -> Set UnboundSplice attrSplices (_, value) = Set.fromList (SpliceAttribute <$> attrSpliceRefs value) --- | Extract the names from any @${name}@ tokens in a string. +{- | 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 (_, "") -> [] @@ -65,5 +69,7 @@ attrSpliceRefs t = case T.breakOn "${" t of let body = T.drop 2 rest in case T.breakOn "}" body of (name, suffix) - | "}" `T.isPrefixOf` suffix -> name : attrSpliceRefs (T.drop 1 suffix) - | otherwise -> [] + | "}" `T.isPrefixOf` suffix + , not (T.any (== '$') name) -> + name : attrSpliceRefs (T.drop 1 suffix) + _ -> attrSpliceRefs body diff --git a/emanote/test/Emanote/View/LintTemplateSpec.hs b/emanote/test/Emanote/View/LintTemplateSpec.hs index 3327b9c1f..0594d8ed3 100644 --- a/emanote/test/Emanote/View/LintTemplateSpec.hs +++ b/emanote/test/Emanote/View/LintTemplateSpec.hs @@ -35,6 +35,14 @@ spec = do scanRenderedHtml "ok.html" "
${not-an-attr}
" `shouldBe` [] + it "still reports a valid ${...} that follows an unbalanced ${" $ do + scanRenderedHtml "ok.html" "x" + `shouldBe` [SpliceAttribute "valid"] + + it "ignores a trailing ${ with no closing brace" $ do + scanRenderedHtml "ok.html" "x" + `shouldBe` [SpliceAttribute "valid"] + describe "formatWarning" $ do it "formats element warnings as a tag" $ do formatWarning (SpliceElement "ema:tite") `shouldBe` "" From 896850b57d7a61fa4a089c92a52d2d1a1de24ce2 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Wed, 6 May 2026 21:15:21 -0400 Subject: [PATCH 4/9] fix(hickey): surface HTML parse failures from scanRenderedHtml scanRenderedHtml used to swallow XmlHtml.parseHTML errors and return [], giving callers a false-clean lint result on unparseable output. Return Either Text [UnboundSplice] so the caller logs the parse error as a distinct diagnostic. --- emanote/src/Emanote/View/LintTemplate.hs | 13 ++++++----- emanote/src/Emanote/View/Template.hs | 19 +++++++++------- emanote/test/Emanote/View/LintTemplateSpec.hs | 22 +++++++++++-------- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/emanote/src/Emanote/View/LintTemplate.hs b/emanote/src/Emanote/View/LintTemplate.hs index e9fc611b4..8fdc2118a 100644 --- a/emanote/src/Emanote/View/LintTemplate.hs +++ b/emanote/src/Emanote/View/LintTemplate.hs @@ -33,13 +33,16 @@ formatWarning = \case SpliceAttribute nm -> "${" <> nm <> "}" {- | Re-parse rendered HTML and collect every unbound splice reference. Returns -a deduplicated, sorted list. An unparseable document yields no warnings — -broken HTML is a separate concern. +@Left@ on a parse failure so the caller can surface that as its own +diagnostic — silence on unparseable HTML would give a false-clean lint. An +XML document is treated as having no warnings (Emanote does not emit XML +through the Heist pipeline this lints). -} -scanRenderedHtml :: FilePath -> ByteString -> [UnboundSplice] +scanRenderedHtml :: FilePath -> ByteString -> Either Text [UnboundSplice] scanRenderedHtml fp bs = case X.parseHTML fp bs of - Right (X.HtmlDocument _ _ nodes) -> Set.toAscList (foldMap nodeSplices nodes) - _ -> [] + Left err -> Left (toText err) + Right (X.HtmlDocument _ _ nodes) -> Right (Set.toAscList (foldMap nodeSplices nodes)) + Right X.XmlDocument {} -> Right [] nodeSplices :: X.Node -> Set UnboundSplice nodeSplices = \case diff --git a/emanote/src/Emanote/View/Template.hs b/emanote/src/Emanote/View/Template.hs index ee96b40f1..b9932908f 100644 --- a/emanote/src/Emanote/View/Template.hs +++ b/emanote/src/Emanote/View/Template.hs @@ -77,14 +77,17 @@ warning for each fresh @(route, splice)@ pair. -} warnUnboundSplices :: (MonadIO m, MonadLogger m) => Text -> Ema.Asset LByteString -> m () warnUnboundSplices routeUrl = \case - Ema.AssetGenerated Ema.Html bytes -> do - let warnings = scanRenderedHtml (toString routeUrl) (toStrict bytes) - 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 $ \w -> - logW $ "Unbound template splice on '" <> routeUrl <> "': " <> formatWarning w + Ema.AssetGenerated Ema.Html bytes -> + case scanRenderedHtml (toString routeUrl) (toStrict bytes) of + Left err -> + logW $ "Could not lint template output for '" <> routeUrl <> "': " <> 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 $ \w -> + logW $ "Unbound template splice on '" <> routeUrl <> "': " <> formatWarning w _ -> pass {- | Process-wide cache of @(route, splice)@ pairs already logged. Lives at the diff --git a/emanote/test/Emanote/View/LintTemplateSpec.hs b/emanote/test/Emanote/View/LintTemplateSpec.hs index 0594d8ed3..57c290ea9 100644 --- a/emanote/test/Emanote/View/LintTemplateSpec.hs +++ b/emanote/test/Emanote/View/LintTemplateSpec.hs @@ -9,39 +9,43 @@ spec = do describe "scanRenderedHtml" $ do it "ignores well-rendered HTML with no splice survivors" $ do scanRenderedHtml "ok.html" "

hello

" - `shouldBe` [] + `shouldBe` Right [] it "reports an element whose tag name still has a Heist-style colon" $ do scanRenderedHtml "ok.html" "" - `shouldBe` [SpliceElement "ema:tite"] + `shouldBe` Right [SpliceElement "ema:tite"] it "reports nested unbound element splices" $ do scanRenderedHtml "ok.html" "
x
" - `shouldBe` [SpliceElement "ema:foo"] + `shouldBe` Right [SpliceElement "ema:foo"] it "reports a literal ${name} that survived as an attribute value" $ do scanRenderedHtml "ok.html" "x" - `shouldBe` [SpliceAttribute "value:siteUrl"] + `shouldBe` Right [SpliceAttribute "value:siteUrl"] it "reports multiple ${...} references in one attribute" $ do scanRenderedHtml "ok.html" "\"${a}" - `shouldBe` [SpliceAttribute "a", SpliceAttribute "b"] + `shouldBe` Right [SpliceAttribute "a", SpliceAttribute "b"] it "deduplicates repeated occurrences across the document" $ do scanRenderedHtml "ok.html" "" - `shouldBe` [SpliceElement "ema:foo"] + `shouldBe` Right [SpliceElement "ema:foo"] it "leaves text nodes alone (e.g. ${...} appearing inside a
)" $ do
       scanRenderedHtml "ok.html" "
${not-an-attr}
" - `shouldBe` [] + `shouldBe` Right [] it "still reports a valid ${...} that follows an unbalanced ${" $ do scanRenderedHtml "ok.html" "x" - `shouldBe` [SpliceAttribute "valid"] + `shouldBe` Right [SpliceAttribute "valid"] it "ignores a trailing ${ with no closing brace" $ do scanRenderedHtml "ok.html" "x" - `shouldBe` [SpliceAttribute "valid"] + `shouldBe` Right [SpliceAttribute "valid"] + + it "surfaces a parse failure as Left so callers do not get a false-clean lint" $ do + scanRenderedHtml "bad.html" "
" + `shouldSatisfy` isLeft describe "formatWarning" $ do it "formats element warnings as a tag" $ do From 68c8c77a91590a134a0ed39d1280f6e76ca665e4 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Wed, 6 May 2026 21:15:52 -0400 Subject: [PATCH 5/9] refactor(hickey): document elementSplice colon heuristic Make the trade-off explicit: HTML5 tags are never colon-bearing and inline SVG/MathML uses unprefixed forms, so any colon in a rendered tag name is unambiguously a Heist splice survivor. If a future legitimate use of a colon tag arises, an allow-list belongs at this function. --- emanote/src/Emanote/View/LintTemplate.hs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/emanote/src/Emanote/View/LintTemplate.hs b/emanote/src/Emanote/View/LintTemplate.hs index 8fdc2118a..217ba1ef1 100644 --- a/emanote/src/Emanote/View/LintTemplate.hs +++ b/emanote/src/Emanote/View/LintTemplate.hs @@ -52,6 +52,11 @@ nodeSplices = \case <> foldMap nodeSplices children _ -> mempty +{- | Empirical heuristic: any element name with a @:@ is a Heist splice. +Plain HTML5 tag names never contain a colon, and SVG/MathML inlined into +HTML5 uses unprefixed forms (@@, @@, @@). If a future +legitimate use of a colon-bearing tag arises, an allow-list belongs here. +-} elementSplice :: Text -> Set UnboundSplice elementSplice name | T.any (== ':') name = one (SpliceElement name) From 21a2c1b4ec729f4b4cc6857874363b0943f4764d Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Wed, 6 May 2026 21:17:54 -0400 Subject: [PATCH 6/9] =?UTF-8?q?fix(police):=20no-silent-error-swallowing?= =?UTF-8?q?=20=E2=80=94=20comment=20why=20non-HTML=20lint=20is=20skipped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also extend the lintWarningCache haddock with the practical growth bound, so a future reader knows the unbounded-by-type Set has a small bound in practice (routes × distinct typos) and the lack of eviction is intentional. --- emanote/src/Emanote/View/Template.hs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/emanote/src/Emanote/View/Template.hs b/emanote/src/Emanote/View/Template.hs index b9932908f..f025c56a3 100644 --- a/emanote/src/Emanote/View/Template.hs +++ b/emanote/src/Emanote/View/Template.hs @@ -88,11 +88,15 @@ warnUnboundSplices routeUrl = \case in (Set.union seen entries, snd <$> Set.toAscList new) forM_ fresh $ \w -> logW $ "Unbound template splice on '" <> routeUrl <> "': " <> formatWarning w + -- Static files and Atom/JSON assets bypass the Heist render path, so + -- there is no template-substitution surface to lint here. _ -> pass {- | 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. +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)) From 2e44022ab8d57eff7494243a7c391623c88e0243 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Wed, 6 May 2026 21:33:45 -0400 Subject: [PATCH 7/9] =?UTF-8?q?refactor(police):=20elegance=20=E2=80=94=20?= =?UTF-8?q?collapse=20internal=20Sets=20in=20nodeSplices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-node Set construction (one (SpliceElement …), Set.fromList, foldMap-via-Set.union) with a [UnboundSplice] accumulator deduped once at the document root via Relude's sortNub. Same semantics, fewer intermediate Sets — the empty-attribute / empty-children case no longer pays for tree allocation it discards. --- emanote/src/Emanote/View/LintTemplate.hs | 34 +++++++++++------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/emanote/src/Emanote/View/LintTemplate.hs b/emanote/src/Emanote/View/LintTemplate.hs index 217ba1ef1..579ac1d09 100644 --- a/emanote/src/Emanote/View/LintTemplate.hs +++ b/emanote/src/Emanote/View/LintTemplate.hs @@ -15,7 +15,6 @@ module Emanote.View.LintTemplate ( formatWarning, ) where -import Data.Set qualified as Set import Data.Text qualified as T import Relude import Text.XmlHtml qualified as X @@ -41,29 +40,28 @@ through the Heist pipeline this lints). scanRenderedHtml :: FilePath -> ByteString -> Either Text [UnboundSplice] scanRenderedHtml fp bs = case X.parseHTML fp bs of Left err -> Left (toText err) - Right (X.HtmlDocument _ _ nodes) -> Right (Set.toAscList (foldMap nodeSplices nodes)) + Right (X.HtmlDocument _ _ nodes) -> Right (sortNub (foldMap nodeSplices nodes)) Right X.XmlDocument {} -> Right [] -nodeSplices :: X.Node -> Set UnboundSplice -nodeSplices = \case - X.Element name attrs children -> - elementSplice name - <> foldMap attrSplices attrs - <> foldMap nodeSplices children - _ -> mempty +{- | Walk an 'X.Node' (and its descendants) into a list of splice references. +Duplicates are collapsed at the document root in 'scanRenderedHtml' rather +than per node — it costs less to fold once than to merge intermediate Sets. -{- | Empirical heuristic: any element name with a @:@ is a Heist splice. -Plain HTML5 tag names never contain a colon, and SVG/MathML inlined into -HTML5 uses unprefixed forms (@@, @@, @@). If a future +The colon heuristic: any element name with a @:@ is a Heist splice. Plain +HTML5 tag names never contain a colon, and SVG/MathML inlined into HTML5 +uses unprefixed forms (@@, @@, @@). If a future legitimate use of a colon-bearing tag arises, an allow-list belongs here. -} -elementSplice :: Text -> Set UnboundSplice -elementSplice name - | T.any (== ':') name = one (SpliceElement name) - | otherwise = mempty +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) -> Set UnboundSplice -attrSplices (_, value) = Set.fromList (SpliceAttribute <$> attrSpliceRefs value) +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. From bddaea8eecc99bb467ffdc77a135f6257fccd38c Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Wed, 6 May 2026 21:34:59 -0400 Subject: [PATCH 8/9] =?UTF-8?q?refactor(police):=20elegance=20=E2=80=94=20?= =?UTF-8?q?share=20log=20prefix=20between=20lint=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both warnUnboundSplices code paths emit "Template lint on '': <…>". Extract a local 'warn' helper so the route locator phrasing matches between parse-failure and unbound-splice messages, and grep-by-route stays deterministic. Also drop the warnUnboundSplices haddock that restated its name in prose and replace it with a why-pointer at LintTemplate (where the scanning rationale already lives). --- emanote/src/Emanote/View/Template.hs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/emanote/src/Emanote/View/Template.hs b/emanote/src/Emanote/View/Template.hs index f025c56a3..3dca96b65 100644 --- a/emanote/src/Emanote/View/Template.hs +++ b/emanote/src/Emanote/View/Template.hs @@ -72,25 +72,26 @@ emanoteSiteOutput rp model' r = do guard $ not $ T.null prefix pure prefix -{- | Scan the rendered HTML for unbound Heist splice references and log a -warning for each fresh @(route, splice)@ pair. +{- | 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 -> - logW $ "Could not lint template output for '" <> routeUrl <> "': " <> err + 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 $ \w -> - logW $ "Unbound template splice on '" <> routeUrl <> "': " <> formatWarning w + 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' From 972a4cdd0656adfe7f989889f47ce25806846671 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Wed, 6 May 2026 21:53:05 -0400 Subject: [PATCH 9/9] perf(lint): byte-level pre-check skips parseHTML when no marker present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lint runs on every rendered HTML route, and X.parseHTML + AST walk add ~1.85s on the docs/ build (57 routes, 2.13s → 3.92s). Add a conservative byte scan: if the bytes contain neither a literal "\${" token nor a colon inside any tag opener, there cannot be an unbound splice survivor and we return Right [] without parsing. Brings the docs/ overhead from +85% to ~+9% (2.13s → 2.32s). Pages with typo'd splices still pay the full parse, which is the rare case the lint exists to catch. --- emanote/src/Emanote/View/LintTemplate.hs | 39 +++++++++++++++++-- emanote/test/Emanote/View/LintTemplateSpec.hs | 7 +++- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/emanote/src/Emanote/View/LintTemplate.hs b/emanote/src/Emanote/View/LintTemplate.hs index 579ac1d09..90b621f14 100644 --- a/emanote/src/Emanote/View/LintTemplate.hs +++ b/emanote/src/Emanote/View/LintTemplate.hs @@ -15,6 +15,8 @@ module Emanote.View.LintTemplate ( formatWarning, ) where +import Data.ByteString qualified as BS +import Data.ByteString.Char8 qualified as BSC import Data.Text qualified as T import Relude import Text.XmlHtml qualified as X @@ -38,10 +40,39 @@ XML document is treated as having no warnings (Emanote does not emit XML through the Heist pipeline this lints). -} scanRenderedHtml :: FilePath -> ByteString -> Either Text [UnboundSplice] -scanRenderedHtml fp bs = case X.parseHTML fp bs of - Left err -> Left (toText err) - Right (X.HtmlDocument _ _ nodes) -> Right (sortNub (foldMap nodeSplices nodes)) - Right X.XmlDocument {} -> Right [] +scanRenderedHtml fp bs + -- Cheap byte-level pre-check: if the rendered output has neither a literal + -- @${@ token (an unbound attribute splice always survives as @${name}@) nor + -- a colon inside any tag opener (the only way a @<…:…>@ element can leak), + -- there is nothing to find. Lets the common case skip 'X.parseHTML' and + -- the AST walk entirely. + | not (hasSpliceMarker bs) = Right [] + | otherwise = case X.parseHTML fp bs of + Left err -> Left (toText err) + Right (X.HtmlDocument _ _ nodes) -> Right (sortNub (foldMap nodeSplices nodes)) + Right X.XmlDocument {} -> Right [] + +{- | Conservative byte scan: returns 'True' when @bs@ /possibly/ contains an +unbound splice survivor. False positives are fine — they trigger the full +parse, which then correctly reports zero warnings. +-} +hasSpliceMarker :: ByteString -> Bool +hasSpliceMarker bs = "${" `BS.isInfixOf` bs || hasColonTagName bs + +hasColonTagName :: ByteString -> Bool +hasColonTagName = go + where + go bs = case BSC.elemIndex '<' bs of + Nothing -> False + Just i -> + let after = BS.drop (i + 1) bs + tagStart = case BSC.uncons after of + Just ('/', rest) -> rest + _ -> after + (name, _) = BSC.break isNameEnd tagStart + in BSC.elem ':' name + || maybe False (\j -> go (BS.drop (j + 1) after)) (BSC.elemIndex '>' after) + isNameEnd c = c == ' ' || c == '\t' || c == '\n' || c == '/' || c == '>' {- | Walk an 'X.Node' (and its descendants) into a list of splice references. Duplicates are collapsed at the document root in 'scanRenderedHtml' rather diff --git a/emanote/test/Emanote/View/LintTemplateSpec.hs b/emanote/test/Emanote/View/LintTemplateSpec.hs index 57c290ea9..50f29c4e9 100644 --- a/emanote/test/Emanote/View/LintTemplateSpec.hs +++ b/emanote/test/Emanote/View/LintTemplateSpec.hs @@ -43,9 +43,12 @@ spec = do scanRenderedHtml "ok.html" "x" `shouldBe` Right [SpliceAttribute "valid"] - it "surfaces a parse failure as Left so callers do not get a false-clean lint" $ do + 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" "
" - `shouldSatisfy` isLeft + `shouldBe` Right [] describe "formatWarning" $ do it "formats element warnings as a tag" $ do