From 7a6150adc33afe85e739a3be3e4b260d7921538c Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar <3998+srid@users.noreply.github.com> Date: Tue, 5 Dec 2023 17:51:13 -0500 Subject: [PATCH] Start supporting Obsidian-style callouts (#466) --- docs/guide/markdown.md | 29 +++++ emanote/default/templates/base.tpl | 49 +++++++- .../filters/callout-icon-failure.tpl | 2 + .../templates/filters/callout-icon-info.tpl | 3 + .../templates/filters/callout-icon-note.tpl | 2 + .../templates/filters/callout-icon-tip.tpl | 3 + .../filters/callout-icon-warning.tpl | 3 + emanote/default/templates/filters/callout.tpl | 20 ++++ emanote/emanote.cabal | 4 +- emanote/src/Emanote.hs | 2 + .../src/Emanote/Pandoc/Renderer/Callout.hs | 113 ++++++++++++++++++ .../Emanote/Pandoc/Renderer/CalloutSpec.hs | 15 +++ 12 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 emanote/default/templates/filters/callout-icon-failure.tpl create mode 100644 emanote/default/templates/filters/callout-icon-info.tpl create mode 100644 emanote/default/templates/filters/callout-icon-note.tpl create mode 100644 emanote/default/templates/filters/callout-icon-tip.tpl create mode 100644 emanote/default/templates/filters/callout-icon-warning.tpl create mode 100644 emanote/default/templates/filters/callout.tpl create mode 100644 emanote/src/Emanote/Pandoc/Renderer/Callout.hs create mode 100644 emanote/test/Emanote/Pandoc/Renderer/CalloutSpec.hs diff --git a/docs/guide/markdown.md b/docs/guide/markdown.md index d2652693f..c4eb16cd9 100644 --- a/docs/guide/markdown.md +++ b/docs/guide/markdown.md @@ -124,6 +124,35 @@ You can highlight any ==inline text== by wraping them in `==` (ie. `==inline tex [^prop]: See original proposal for this syntax [here](https://talk.commonmark.org/t/highlighting-text-with-the-mark-element/840). +## Callouts + +Emanote supports [Obsidian-style callouts](https://help.obsidian.md/Editing+and+formatting/Callouts).[^callout] To customizing their structure and styling, change `callout.tpl` (and `base.tpl`) in [[html-template|HTML templates]]. + +[^callout]: Not all of Obsidian spec may yet be supported. See https://github.com/srid/emanote/issues/465 for details. + +> [!note] +> This is a note callout +> +> Lorem **ipsum** dolor sit *amet*, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + +> [!info] +> This is an info callout +> +> Lorem **ipsum** dolor sit *amet*, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + +> [!tip] Callouts can have *custom* titles +> Like this one. +> +> Lorem **ipsum** dolor sit *amet*, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + +> [!warning] +> +> Lorem **ipsum** dolor sit *amet*, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + +> [!failure] +> +> Lorem **ipsum** dolor sit *amet*, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + ## More extensions :::{.flex-row .space-y-8} diff --git a/emanote/default/templates/base.tpl b/emanote/default/templates/base.tpl index e3b8b988e..829ae0b71 100644 --- a/emanote/default/templates/base.tpl +++ b/emanote/default/templates/base.tpl @@ -32,6 +32,53 @@ font-family: monospace; } + /* Callouts */ + div.callout { + background-color: #f5f5f5; + padding: 1em 1em 0.5em; + border-radius: 0.5em; + margin-bottom: 1em; + } + + .callout[data-callout="note"] { + --callout-color: 8, 109, 221; + } + + .callout[data-callout="info"] { + --callout-color: 8, 109, 221; + } + + .callout[data-callout="tip"] { + --callout-color: 8, 191, 188; + } + + .callout[data-callout="warning"] { + --callout-color: 236, 117, 0; + } + + .callout[data-callout="failure"] { + --callout-color: 233, 49, 71; + } + + div.callout { + background-color: rgba(var(--callout-color), 0.1); + } + + .callout .callout-title { + color: rgb(var(--callout-color)); + } + + div.callout-title { + display: flex; + align-items: center; + margin-bottom: 0.5em; + font-variation-settings: 'wght' 600; + } + + div.callout-title div.callout-title-inner { + margin-left: 0.5em; + } + /* External link icon */ a[data-linkicon=""]::after { content: "" @@ -92,4 +139,4 @@ - + \ No newline at end of file diff --git a/emanote/default/templates/filters/callout-icon-failure.tpl b/emanote/default/templates/filters/callout-icon-failure.tpl new file mode 100644 index 000000000..42d0ed49d --- /dev/null +++ b/emanote/default/templates/filters/callout-icon-failure.tpl @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/emanote/default/templates/filters/callout-icon-info.tpl b/emanote/default/templates/filters/callout-icon-info.tpl new file mode 100644 index 000000000..88eae187f --- /dev/null +++ b/emanote/default/templates/filters/callout-icon-info.tpl @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/emanote/default/templates/filters/callout-icon-note.tpl b/emanote/default/templates/filters/callout-icon-note.tpl new file mode 100644 index 000000000..2714c7b39 --- /dev/null +++ b/emanote/default/templates/filters/callout-icon-note.tpl @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/emanote/default/templates/filters/callout-icon-tip.tpl b/emanote/default/templates/filters/callout-icon-tip.tpl new file mode 100644 index 000000000..0c537f36c --- /dev/null +++ b/emanote/default/templates/filters/callout-icon-tip.tpl @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/emanote/default/templates/filters/callout-icon-warning.tpl b/emanote/default/templates/filters/callout-icon-warning.tpl new file mode 100644 index 000000000..d6ad19e78 --- /dev/null +++ b/emanote/default/templates/filters/callout-icon-warning.tpl @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/emanote/default/templates/filters/callout.tpl b/emanote/default/templates/filters/callout.tpl new file mode 100644 index 000000000..d9fd03b52 --- /dev/null +++ b/emanote/default/templates/filters/callout.tpl @@ -0,0 +1,20 @@ +
+ + +
+
+ + + +
+
+ +
+
+
+ +
+
\ No newline at end of file diff --git a/emanote/emanote.cabal b/emanote/emanote.cabal index 25379dc6c..14642b2e4 100644 --- a/emanote/emanote.cabal +++ b/emanote/emanote.cabal @@ -1,6 +1,6 @@ cabal-version: 2.4 name: emanote -version: 1.2.0.0 +version: 1.2.1.0 license: AGPL-3.0-only copyright: 2022 Sridhar Ratnakumar maintainer: srid@srid.ca @@ -186,6 +186,7 @@ library Emanote.Pandoc.Markdown.Syntax.HashTag Emanote.Pandoc.Markdown.Syntax.Highlight Emanote.Pandoc.Renderer + Emanote.Pandoc.Renderer.Callout Emanote.Pandoc.Renderer.Embed Emanote.Pandoc.Renderer.Query Emanote.Pandoc.Renderer.Url @@ -244,3 +245,4 @@ test-suite test Emanote.Model.Link.RelSpec Emanote.Model.QuerySpec Emanote.Pandoc.ExternalLinkSpec + Emanote.Pandoc.Renderer.CalloutSpec diff --git a/emanote/src/Emanote.hs b/emanote/src/Emanote.hs index 2d2180d1b..6a010e5de 100644 --- a/emanote/src/Emanote.hs +++ b/emanote/src/Emanote.hs @@ -26,6 +26,7 @@ import Emanote.Model.Link.Rel (ResolvedRelTarget (..)) import Emanote.Model.Type (modelCompileTailwind) import Emanote.Model.Type qualified as Model import Emanote.Pandoc.Renderer +import Emanote.Pandoc.Renderer.Callout qualified as PF import Emanote.Pandoc.Renderer.Embed qualified as PF import Emanote.Pandoc.Renderer.Query qualified as PF import Emanote.Pandoc.Renderer.Url qualified as PF @@ -144,6 +145,7 @@ defaultEmanotePandocRenderers = [ PF.embedBlockWikiLinkResolvingSplice , PF.embedBlockRegularLinkResolvingSplice , PF.queryResolvingSplice + , PF.calloutResolvingSplice ] inlineRenderers = PandocRenderers diff --git a/emanote/src/Emanote/Pandoc/Renderer/Callout.hs b/emanote/src/Emanote/Pandoc/Renderer/Callout.hs new file mode 100644 index 000000000..2a117bb5e --- /dev/null +++ b/emanote/src/Emanote/Pandoc/Renderer/Callout.hs @@ -0,0 +1,113 @@ +{-# LANGUAGE RecordWildCards #-} + +module Emanote.Pandoc.Renderer.Callout ( + calloutResolvingSplice, + + -- * For tests + CalloutType (..), + Callout (..), + parseCalloutType, +) where + +import Control.Monad (msum) +import Data.Default (Default (def)) +import Data.Map.Syntax ((##)) +import Data.Text qualified as T +import Emanote.Model (Model) +import Emanote.Model.Title qualified as Tit +import Emanote.Pandoc.Renderer (PandocBlockRenderer) +import Emanote.Route (LMLRoute) +import Heist.Extra qualified as HE +import Heist.Extra.Splices.Pandoc qualified as HP +import Heist.Interpreted qualified as HI +import Relude +import Text.Megaparsec qualified as M +import Text.Megaparsec.Char qualified as M +import Text.Pandoc.Definition qualified as B + +calloutResolvingSplice :: PandocBlockRenderer Model LMLRoute +calloutResolvingSplice _model _nr ctx _noteRoute blk = do + B.BlockQuote blks <- pure blk + callout <- parseCallout blks + pure $ do + tpl <- HE.lookupHtmlTemplateMust "/templates/filters/callout" + HE.runCustomTemplate tpl $ do + "callout:type" ## HI.textSplice (T.toLower $ show $ type_ callout) + "callout:title" ## Tit.titleSplice ctx id $ Tit.fromInlines (title callout) + "callout:body" ## HP.pandocSplice ctx $ B.Pandoc mempty (body callout) + "query" ## + HI.textSplice (show blks) + +{- | Obsidian callout type + +TODO: Add the rest, from https://help.obsidian.md/Editing+and+formatting/Callouts#Supported%20types +-} +data CalloutType + = Note + | Info + | Tip + | Warning + | Failure + deriving stock (Eq, Ord, Show, Enum, Bounded) + +instance Default CalloutType where + def = Note + +data Callout = Callout + { type_ :: CalloutType + , title :: [B.Inline] + , body :: [B.Block] + } + deriving stock (Eq, Ord, Show) + +-- | Parse `Callout` from blockquote blocks +parseCallout :: [B.Block] -> Maybe Callout +parseCallout = parseObsidianCallout + +-- | Parse according to https://help.obsidian.md/Editing+and+formatting/Callouts +parseObsidianCallout :: [B.Block] -> Maybe Callout +parseObsidianCallout blks = do + B.Para (B.Str calloutType : inlines) : body' <- pure blks + type_ <- parseCalloutType calloutType + let (title', mFirstPara) = disrespectSoftbreak inlines + title = if null title' then defaultTitle type_ else title' + body = maybe body' (: body') mFirstPara + pure $ Callout {..} + +{- | If there is a `B.SoftBreak`, treat it as paragraph break. + +We do this to support Obsidian callouts where the first paragraph can start +immediately after the callout heading without a newline break in between. +-} +disrespectSoftbreak :: [B.Inline] -> ([B.Inline], Maybe B.Block) +disrespectSoftbreak = \case + [] -> ([], Nothing) + (B.SoftBreak : rest) -> ([], Just (B.Para rest)) + (x : xs) -> + let (a, b) = disrespectSoftbreak xs + in (x : a, b) + +defaultTitle :: CalloutType -> [B.Inline] +defaultTitle t = + [B.Str $ show t] + +-- | Parse, for example, "[!tip]" into 'Tip'. +parseCalloutType :: Text -> Maybe CalloutType +parseCalloutType = + rightToMaybe . parse parser "" + where + parser :: M.Parsec Void Text CalloutType + parser = do + void $ M.string "[!" + s <- T.toLower . toText <$> M.some M.letterChar + void $ M.string "]" + maybe (fail "Unknown") pure $ parseType s + parseType :: Text -> Maybe CalloutType + parseType s = + msum $ flip fmap (universe @CalloutType) $ \t -> do + guard $ s == T.toLower (show @Text t) + Just t + parse :: M.Parsec Void Text a -> String -> Text -> Either Text a + parse p fn = + first (toText . M.errorBundlePretty) + . M.parse (p <* M.eof) fn diff --git a/emanote/test/Emanote/Pandoc/Renderer/CalloutSpec.hs b/emanote/test/Emanote/Pandoc/Renderer/CalloutSpec.hs new file mode 100644 index 000000000..0b967cb10 --- /dev/null +++ b/emanote/test/Emanote/Pandoc/Renderer/CalloutSpec.hs @@ -0,0 +1,15 @@ +module Emanote.Pandoc.Renderer.CalloutSpec where + +import Emanote.Pandoc.Renderer.Callout +import Hedgehog +import Relude +import Test.Hspec +import Test.Hspec.Hedgehog + +spec :: Spec +spec = do + describe "callout" $ do + it "type" . hedgehog $ do + parseCalloutType "[!tip]" === Just Tip + parseCalloutType "[!Note]" === Just Note + parseCalloutType "[!INFO]" === Just Info