packages feed

mmark-ext 0.2.1.5 → 0.3.0.0

raw patch · 46 files changed

+2914/−503 lines, 46 filesdep +bytestringdep +containersdep +directorydep ~basedep ~mmarkdep ~skylightingsetup-changedPVP ok

version bump matches the API change (PVP)

Dependencies added: bytestring, containers, directory, filepath, megaparsec

Dependency ranges changed: base, mmark, skylighting

API changes (from Hackage documentation)

- Text.MMark.Extension.FontAwesome: fontAwesome :: Extension
- Text.MMark.Extension.LinkTarget: linkTarget :: Extension
- Text.MMark.Extension.ObfuscateEmail: obfuscateEmail :: Text -> Extension
+ Text.MMark.Extension.Emoji: defaultEmoji :: Map Text Text
+ Text.MMark.Extension.Emoji: emoji :: Bni -> Trans Bni
+ Text.MMark.Extension.Emoji: emojiWith :: Map Text Text -> Bni -> Trans Bni
+ Text.MMark.Extension.Footnotes: data Footnotes
+ Text.MMark.Extension.Footnotes: footnoteScanner :: Fold Bni Footnotes
+ Text.MMark.Extension.Footnotes: instance GHC.Internal.Base.Monoid Text.MMark.Extension.Footnotes.Footnotes
+ Text.MMark.Extension.Footnotes: instance GHC.Internal.Base.Semigroup Text.MMark.Extension.Footnotes.Footnotes
+ Text.MMark.Extension.Footnotes: validateFootnotes :: Footnotes -> Trans ()
+ Text.MMark.Extension.Heading: checkHeadings :: Headings -> Trans ()
+ Text.MMark.Extension.Heading: data Headings
+ Text.MMark.Extension.Heading: headingProblems :: Headings -> [(Span, Text)]
+ Text.MMark.Extension.Heading: headingScanner :: Fold Bni Headings
+ Text.MMark.Extension.Heading: instance GHC.Internal.Base.Monoid Text.MMark.Extension.Heading.Headings
+ Text.MMark.Extension.Heading: instance GHC.Internal.Base.Semigroup Text.MMark.Extension.Heading.Headings
+ Text.MMark.Extension.Icons: checkIcons :: Map Text a -> Bni -> Trans Bni
+ Text.MMark.Extension.Icons: checkIconsWith :: RText 'Scheme -> Map Text a -> Bni -> Trans Bni
+ Text.MMark.Extension.Icons: icons :: Map Text (Html ()) -> RenderExtension
+ Text.MMark.Extension.Icons: iconsWith :: RText 'Scheme -> Text -> Map Text (Html ()) -> RenderExtension
+ Text.MMark.Extension.Image: checkAltText :: Bni -> Trans Bni
+ Text.MMark.Extension.Image: imageDimensions :: Map Span (Maybe (Int, Int)) -> RenderExtension
+ Text.MMark.Extension.Image: imageScanner :: Fold Bni (Map Span URI)
+ Text.MMark.Extension.Image: imageSizeOf :: FilePath -> IO (Maybe (Int, Int))
+ Text.MMark.Extension.Image: lazyImages :: RenderExtension
+ Text.MMark.Extension.LineHighlight: lineHighlight :: RenderExtension
+ Text.MMark.Extension.LineHighlight: parseLineSpec :: Text -> Maybe (Maybe Text, [Int])
+ Text.MMark.Extension.Link: checkExternal :: (URI -> IO Bool) -> Bni -> TransT IO Bni
+ Text.MMark.Extension.Link: checkFragments :: Set Text -> Bni -> Trans Bni
+ Text.MMark.Extension.Link: checkLocalFiles :: FilePath -> Bni -> TransT IO Bni
+ Text.MMark.Extension.Link: headerIdScanner :: Fold Bni (Set Text)
+ Text.MMark.Extension.Link: linkTarget :: RenderExtension
+ Text.MMark.Extension.Mermaid: mermaid :: RenderExtension
+ Text.MMark.Extension.Mermaid: mermaidScanner :: Fold Bni (Map Span Text)
+ Text.MMark.Extension.Mermaid: mermaidSvg :: Map Span Text -> RenderExtension
+ Text.MMark.Extension.Metadata: Metadata :: !Int -> Maybe Text -> Maybe URI -> Maybe Text -> Metadata
+ Text.MMark.Extension.Metadata: [metaImage] :: Metadata -> Maybe URI
+ Text.MMark.Extension.Metadata: [metaLead] :: Metadata -> Maybe Text
+ Text.MMark.Extension.Metadata: [metaTitle] :: Metadata -> Maybe Text
+ Text.MMark.Extension.Metadata: [metaWords] :: Metadata -> !Int
+ Text.MMark.Extension.Metadata: data Metadata
+ Text.MMark.Extension.Metadata: instance GHC.Classes.Eq Text.MMark.Extension.Metadata.Metadata
+ Text.MMark.Extension.Metadata: instance GHC.Internal.Base.Monoid Text.MMark.Extension.Metadata.Metadata
+ Text.MMark.Extension.Metadata: instance GHC.Internal.Base.Semigroup Text.MMark.Extension.Metadata.Metadata
+ Text.MMark.Extension.Metadata: instance GHC.Internal.Show.Show Text.MMark.Extension.Metadata.Metadata
+ Text.MMark.Extension.Metadata: metadataScanner :: Fold Bni Metadata
+ Text.MMark.Extension.Metadata: readingTime :: Int -> Metadata -> Int
+ Text.MMark.Extension.Permalinks: permalinks :: RenderExtension
+ Text.MMark.Extension.Permalinks: permalinksWith :: (Int -> Bool) -> Text -> Maybe Text -> Html () -> RenderExtension
- Text.MMark.Extension.Comment: commentParagraph :: Text -> Extension
+ Text.MMark.Extension.Comment: commentParagraph :: Text -> RenderExtension
- Text.MMark.Extension.Footnotes: footnotes :: Extension
+ Text.MMark.Extension.Footnotes: footnotes :: RenderExtension
- Text.MMark.Extension.GhcSyntaxHighlighter: ghcSyntaxHighlighter :: Extension
+ Text.MMark.Extension.GhcSyntaxHighlighter: ghcSyntaxHighlighter :: RenderExtension
- Text.MMark.Extension.Kbd: kbd :: Extension
+ Text.MMark.Extension.Kbd: kbd :: RenderExtension
- Text.MMark.Extension.MathJax: mathJax :: Maybe Char -> Extension
+ Text.MMark.Extension.MathJax: mathJax :: Maybe Char -> RenderExtension
- Text.MMark.Extension.PunctuationPrettifier: punctuationPrettifier :: Extension
+ Text.MMark.Extension.PunctuationPrettifier: punctuationPrettifier :: Bni -> Trans Bni
- Text.MMark.Extension.Skylighting: skylighting :: Extension
+ Text.MMark.Extension.Skylighting: skylighting :: RenderExtension
- Text.MMark.Extension.TableOfContents: toc :: Text -> Toc -> Extension
+ Text.MMark.Extension.TableOfContents: toc :: Text -> Toc -> Bni -> Trans Bni

Files

CHANGELOG.md view
@@ -1,3 +1,25 @@+## MMark Ext 0.3.0.0++* The package now requires `mmark-0.1` or later.++* Added the following modules:++    * `Text.MMark.Extension.Emoji`+    * `Text.MMark.Extension.Heading`+    * `Text.MMark.Extension.Icons`+    * `Text.MMark.Extension.Image`+    * `Text.MMark.Extension.LineHighlight`+    * `Text.MMark.Extension.Link`+    * `Text.MMark.Extension.Mermaid`+    * `Text.MMark.Extension.Metadata`+    * `Text.MMark.Extension.Permalinks`++* Removed the following modules:++    * `Text.MMark.Extension.FontAwesome`+    * `Text.MMark.Extension.LinkTarget`+    * `Text.MMark.Extension.ObfuscateEmail`+ ## MMark Ext 0.2.1.5  * The test suite now passes with `modern-uri-0.3.4.4`.
README.md view
@@ -4,7 +4,7 @@ [![Hackage](https://img.shields.io/hackage/v/mmark-ext.svg?style=flat)](https://hackage.haskell.org/package/mmark-ext) [![Stackage Nightly](http://stackage.org/package/mmark-ext/badge/nightly)](http://stackage.org/nightly/package/mmark-ext) [![Stackage LTS](http://stackage.org/package/mmark-ext/badge/lts)](http://stackage.org/lts/package/mmark-ext)-![CI](https://github.com/mmark-md/mmark-ext/workflows/CI/badge.svg?branch=master)+[![CI](https://github.com/mmark-md/mmark-ext/actions/workflows/ci.yaml/badge.svg)](https://github.com/mmark-md/mmark-ext/actions/workflows/ci.yaml)  Commonly useful extensions for the [MMark](https://hackage.haskell.org/package/mmark) markdown processor.@@ -20,4 +20,4 @@  Copyright © 2017–present Mark Karpov -Distributed under BSD 3 clause license.+Distributed under the BSD 3-clause license.
− Setup.hs
@@ -1,6 +0,0 @@-module Main (main) where--import Distribution.Simple--main :: IO ()-main = defaultMain
Text/MMark/Extension/Comment.hs view
@@ -17,21 +17,21 @@ import Control.Monad import Data.List.NonEmpty (NonEmpty (..)) import Data.Text (Text)-import qualified Data.Text as T-import Text.MMark.Extension (Block (..), Extension, Inline (..))-import qualified Text.MMark.Extension as Ext+import Data.Text qualified as T+import Text.MMark.Render (Block (..), Inline (..), RenderExtension)+import Text.MMark.Render qualified as Ext  -- | This extension removes top-level paragraphs starting with the given -- sequence of non-markup characters. commentParagraph ::   -- | Sequence of characters that starts a comment   Text ->-  Extension+  RenderExtension commentParagraph commentPrefix = Ext.blockRender $ \old block ->   case block of-    p@(Paragraph (ois, _)) ->+    p@(Paragraph _ (ois, _)) ->       case Ext.getOis ois of-        (Plain txt :| _) ->+        (Plain _ txt :| _) ->           unless (commentPrefix `T.isPrefixOf` txt) $             old p         _ -> old p
Text/MMark/Extension/Common.hs view
@@ -14,12 +14,13 @@ -- > import qualified Text.MMark.Extension.Common as Ext -- -- Here is an example that uses several extensions from this module at the--- same time, it should give you an idea where to start:+-- same time; it should give you an idea where to start: -- -- > {-# LANGUAGE OverloadedStrings #-} -- > -- > module Main (main) where -- >+-- > import           Control.Monad               ((>=>)) -- > import qualified Data.Text.IO                as T -- > import qualified Data.Text.Lazy.IO           as TL -- > import qualified Lucid                       as L@@ -33,25 +34,36 @@ -- >   txt <- T.readFile input -- >   case MMark.parse input txt of -- >     Left bundle -> putStrLn (M.errorBundlePretty bundle)--- >     Right r ->--- >       let toc = MMark.runScanner r (Ext.tocScanner (> 1))--- >       in TL.writeFile "output.html"--- >           . L.renderText--- >           . MMark.render--- >           . MMark.useExtensions--- >               [ Ext.toc "toc" toc--- >               , Ext.punctuationPrettifier--- >               , Ext.skylighting ]--- >           $ r+-- >     Right r -> do+-- >       let toc = MMark.runScanner (Ext.tocScanner (> 1)) r+-- >           fns = MMark.runScanner Ext.footnoteScanner r+-- >           trans = Ext.toc "toc" toc >=> Ext.punctuationPrettifier+-- >           renderExts = Ext.skylighting <> Ext.footnotes+-- >       case MMark.runCheck (Ext.validateFootnotes fns) r of+-- >         Left errs -> putStrLn (M.errorBundlePretty errs)+-- >         Right () -> return ()+-- >       case MMark.runTrans trans r of+-- >         Left errs -> putStrLn (M.errorBundlePretty errs)+-- >         Right r' ->+-- >           TL.writeFile "output.html"+-- >             . L.renderText+-- >             . MMark.render renderExts+-- >             $ r' module Text.MMark.Extension.Common   ( module Text.MMark.Extension.Comment,-    module Text.MMark.Extension.FontAwesome,+    module Text.MMark.Extension.Emoji,     module Text.MMark.Extension.Footnotes,     module Text.MMark.Extension.GhcSyntaxHighlighter,+    module Text.MMark.Extension.Heading,+    module Text.MMark.Extension.Icons,+    module Text.MMark.Extension.Image,     module Text.MMark.Extension.Kbd,-    module Text.MMark.Extension.LinkTarget,+    module Text.MMark.Extension.LineHighlight,+    module Text.MMark.Extension.Link,     module Text.MMark.Extension.MathJax,-    module Text.MMark.Extension.ObfuscateEmail,+    module Text.MMark.Extension.Mermaid,+    module Text.MMark.Extension.Metadata,+    module Text.MMark.Extension.Permalinks,     module Text.MMark.Extension.PunctuationPrettifier,     module Text.MMark.Extension.Skylighting,     module Text.MMark.Extension.TableOfContents,@@ -59,13 +71,19 @@ where  import Text.MMark.Extension.Comment-import Text.MMark.Extension.FontAwesome+import Text.MMark.Extension.Emoji import Text.MMark.Extension.Footnotes import Text.MMark.Extension.GhcSyntaxHighlighter+import Text.MMark.Extension.Heading+import Text.MMark.Extension.Icons+import Text.MMark.Extension.Image import Text.MMark.Extension.Kbd-import Text.MMark.Extension.LinkTarget+import Text.MMark.Extension.LineHighlight+import Text.MMark.Extension.Link import Text.MMark.Extension.MathJax-import Text.MMark.Extension.ObfuscateEmail+import Text.MMark.Extension.Mermaid+import Text.MMark.Extension.Metadata+import Text.MMark.Extension.Permalinks import Text.MMark.Extension.PunctuationPrettifier import Text.MMark.Extension.Skylighting import Text.MMark.Extension.TableOfContents
+ Text/MMark/Extension/Emoji.hs view
@@ -0,0 +1,317 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      :  Text.MMark.Extension.Emoji+-- Copyright   :  © 2026–present Mark Karpov+-- License     :  BSD 3 clause+--+-- Maintainer  :  Mark Karpov <markkarpov92@gmail.com>+-- Stability   :  experimental+-- Portability :  portable+--+-- Replace @:shortcode:@ with the emoji it names.+--+-- @since 0.3.0.0+module Text.MMark.Extension.Emoji+  ( emoji,+    emojiWith,+    defaultEmoji,+  )+where++import Data.Map.Strict (Map)+import Data.Map.Strict qualified as M+import Data.Text (Text)+import Data.Text qualified as T+import Text.MMark.Trans (Bni, Inline (..), Trans)+import Text.MMark.Trans qualified as Trans++-- | Replace every @:shortcode:@ of 'defaultEmoji' with the emoji it names,+-- and report every @:shortcode:@ that is not one of them. A name that is+-- not recognized is far more likely to be a typo than something the writer+-- meant to keep.+emoji :: Bni -> Trans Bni+emoji = emojiWith defaultEmoji++-- | Like 'emoji', but you supply the table.+emojiWith :: Map Text Text -> Bni -> Trans Bni+emojiWith table = Trans.bottomUpInlines $ \case+  Plain spn txt -> Plain spn <$> replace spn txt+  other -> return other+  where+    replace spn = fmap T.concat . mapM (piece spn) . chunks+    piece spn = \case+      Left t -> return t+      Right name -> case M.lookup name table of+        Just e -> return e+        Nothing -> do+          Trans.report spn ("there is no emoji called \"" <> name <> "\"")+          return (":" <> name <> ":")++-- | Split text into literal pieces and the shortcodes between them. A+-- shortcode is a run of letters, digits, @_@, @+@, and @-@ between colons.+chunks :: Text -> [Either Text Text]+chunks t =+  case T.breakOn ":" t of+    (before, rest)+      | T.null rest -> [Left before | not (T.null before)]+      | otherwise ->+          let (name, rest') = T.breakOn ":" (T.drop 1 rest)+           in if T.null rest' || T.null name || not (T.all nameChar name)+                then case chunks (T.drop 1 rest) of+                  cs -> Left (before <> ":") : cs+                else Left before : Right name : chunks (T.drop 1 rest')+  where+    nameChar c = c `elem` ("_+-" :: String) || c `elem` ['a' .. 'z'] || c `elem` ['0' .. '9']++-- | The table 'emoji' uses: a couple of hundred of the shortcodes that come+-- up most often, grouped below by what they are about. The names are the+-- familiar ones, so @:tada:@, @:+1:@, and @:warning:@ mean what you expect.+defaultEmoji :: Map Text Text+defaultEmoji =+  M.fromList+    [ -- Faces and emotions+      ("smile", "\128578"),+      ("grin", "\128512"),+      ("grinning", "\128512"),+      ("laughing", "\128514"),+      ("joy", "\128514"),+      ("sweat_smile", "\128517"),+      ("rofl", "\129315"),+      ("wink", "\128521"),+      ("blush", "\128522"),+      ("heart_eyes", "\128525"),+      ("star_struck", "\129321"),+      ("sunglasses", "\128526"),+      ("smirk", "\128527"),+      ("stuck_out_tongue", "\128539"),+      ("nerd_face", "\129299"),+      ("thinking", "\129300"),+      ("zipper_mouth_face", "\129296"),+      ("face_with_monocle", "\129488"),+      ("neutral_face", "\128528"),+      ("confused", "\128533"),+      ("upside_down_face", "\128579"),+      ("worried", "\128543"),+      ("cry", "\128546"),+      ("sob", "\128557"),+      ("tired_face", "\128555"),+      ("scream", "\128561"),+      ("angry", "\128544"),+      ("rage", "\128545"),+      ("sleeping", "\128564"),+      ("yawning_face", "\129393"),+      ("exploding_head", "\129327"),+      ("partying_face", "\129395"),+      ("shrug", "\129335"),+      ("facepalm", "\129318"),+      -- Hands+      ("thumbsup", "\128077"),+      ("thumbsdown", "\128078"),+      ("+1", "\128077"),+      ("-1", "\128078"),+      ("ok_hand", "\128076"),+      ("v", "\9996\65039"),+      ("point_up", "\9757\65039"),+      ("point_right", "\128073"),+      ("point_left", "\128072"),+      ("wave", "\128075"),+      ("clap", "\128079"),+      ("raised_hands", "\128588"),+      ("pray", "\128591"),+      ("handshake", "\129309"),+      ("muscle", "\128170"),+      ("writing_hand", "\9997\65039"),+      ("eyes", "\128064"),+      -- Hearts+      ("heart", "\10084\65039"),+      ("broken_heart", "\128148"),+      ("sparkling_heart", "\128150"),+      ("blue_heart", "\128153"),+      ("green_heart", "\128154"),+      ("yellow_heart", "\128155"),+      ("orange_heart", "\129505"),+      ("purple_heart", "\128156"),+      ("black_heart", "\128420"),+      -- Nature and weather+      ("sunny", "\9728\65039"),+      ("crescent_moon", "\127769"),+      ("star", "\11088"),+      ("sparkles", "\10024"),+      ("cloud", "\9729\65039"),+      ("zap", "\9889"),+      ("snowflake", "\10052\65039"),+      ("rainbow", "\127752"),+      ("droplet", "\128167"),+      ("ocean", "\127754"),+      ("earth_americas", "\127758"),+      ("mountain", "\9968\65039"),+      ("seedling", "\127793"),+      ("herb", "\127807"),+      ("four_leaf_clover", "\127808"),+      ("maple_leaf", "\127809"),+      ("cactus", "\127797"),+      ("palm_tree", "\127796"),+      ("fire", "\128293"),+      -- Animals+      ("snail", "\128012"),+      ("turtle", "\128034"),+      ("rabbit", "\128007"),+      ("cat", "\128049"),+      ("dog", "\128054"),+      ("mouse", "\128045"),+      ("horse", "\128052"),+      ("pig", "\128055"),+      ("bear", "\128059"),+      ("panda_face", "\128060"),+      ("fox_face", "\129418"),+      ("monkey", "\128018"),+      ("elephant", "\128024"),+      ("camel", "\128043"),+      ("unicorn", "\129412"),+      ("dragon", "\128009"),+      ("snake", "\128013"),+      ("bird", "\128038"),+      ("owl", "\129417"),+      ("penguin", "\128039"),+      ("fish", "\128031"),+      ("whale", "\128051"),+      ("octopus", "\128025"),+      ("crab", "\129408"),+      ("bug", "\128027"),+      ("bee", "\128029"),+      ("ant", "\128028"),+      ("butterfly", "\129419"),+      -- Food and drink+      ("coffee", "\9749"),+      ("tea", "\127861"),+      ("beer", "\127866"),+      ("wine_glass", "\127863"),+      ("cocktail", "\127864"),+      ("champagne", "\127870"),+      ("clinking_glasses", "\129346"),+      ("pizza", "\127829"),+      ("hamburger", "\127828"),+      ("taco", "\127790"),+      ("sushi", "\127843"),+      ("popcorn", "\127871"),+      ("cake", "\127856"),+      ("birthday", "\127874"),+      ("cookie", "\127850"),+      ("doughnut", "\127849"),+      ("ice_cream", "\127848"),+      ("chocolate_bar", "\127851"),+      ("apple", "\127822"),+      ("banana", "\127820"),+      ("avocado", "\129361"),+      -- Tools and objects+      ("bulb", "\128161"),+      ("wrench", "\128295"),+      ("hammer", "\128296"),+      ("hammer_and_wrench", "\128736\65039"),+      ("nut_and_bolt", "\128297"),+      ("gear", "\9881\65039"),+      ("toolbox", "\129520"),+      ("microscope", "\128300"),+      ("telescope", "\128301"),+      ("mag", "\128269"),+      ("computer", "\128187"),+      ("keyboard", "\9000\65039"),+      ("floppy_disk", "\128190"),+      ("package", "\128230"),+      ("battery", "\128267"),+      ("electric_plug", "\128268"),+      ("camera", "\128247"),+      ("movie_camera", "\127909"),+      ("tv", "\128250"),+      ("bell", "\128276"),+      ("mega", "\128227"),+      ("loudspeaker", "\128226"),+      ("speech_balloon", "\128172"),+      ("thought_balloon", "\128173"),+      ("envelope", "\9993\65039"),+      ("inbox_tray", "\128229"),+      ("outbox_tray", "\128228"),+      ("flashlight", "\128294"),+      ("candle", "\128367\65039"),+      ("broom", "\129529"),+      ("wastebasket", "\128465\65039"),+      ("crystal_ball", "\128302"),+      ("gem", "\128142"),+      ("crown", "\128081"),+      ("trophy", "\127942"),+      ("dart", "\127919"),+      ("game_die", "\127922"),+      ("art", "\127912"),+      ("musical_note", "\127925"),+      ("rocket", "\128640"),+      ("airplane", "\9992\65039"),+      ("hourglass", "\8987"),+      ("alarm_clock", "\9200"),+      ("stopwatch", "\9201\65039"),+      ("calendar", "\128197"),+      ("balance_scale", "\9878\65039"),+      ("chart_with_upwards_trend", "\128200"),+      ("bar_chart", "\128202"),+      -- Paper and files+      ("book", "\128214"),+      ("books", "\128218"),+      ("memo", "\128221"),+      ("pencil2", "\9999\65039"),+      ("scroll", "\128220"),+      ("page_facing_up", "\128196"),+      ("newspaper", "\128240"),+      ("clipboard", "\128203"),+      ("file_folder", "\128193"),+      ("open_file_folder", "\128194"),+      ("paperclip", "\128206"),+      ("pushpin", "\128204"),+      ("bookmark", "\128278"),+      ("label", "\127991\65039"),+      ("link", "\128279"),+      ("lock", "\128274"),+      ("unlock", "\128275"),+      ("closed_lock_with_key", "\128272"),+      ("key", "\128273"),+      ("shield", "\128737\65039"),+      -- Marks and signs+      ("white_check_mark", "\9989"),+      ("heavy_check_mark", "\10004\65039"),+      ("ballot_box_with_check", "\9745\65039"),+      ("x", "\10060"),+      ("question", "\10067"),+      ("exclamation", "\10071"),+      ("warning", "\9888\65039"),+      ("boom", "\128165"),+      ("100", "\128175"),+      ("tada", "\127881"),+      ("checkered_flag", "\127937"),+      ("triangular_flag_on_post", "\128681"),+      ("construction", "\128679"),+      ("rotating_light", "\128680"),+      ("no_entry", "\9940"),+      ("recycle", "\9851\65039"),+      ("infinity", "\9854\65039"),+      ("heavy_plus_sign", "\10133"),+      ("heavy_minus_sign", "\10134"),+      ("arrow_right", "\10145\65039"),+      ("arrow_left", "\11013\65039"),+      ("arrow_up", "\11014\65039"),+      ("arrow_down", "\11015\65039"),+      ("arrows_counterclockwise", "\128260"),+      ("red_circle", "\128308"),+      ("large_blue_circle", "\128309"),+      ("green_circle", "\128994"),+      ("yellow_circle", "\128993"),+      ("orange_circle", "\128992"),+      ("purple_circle", "\128995"),+      ("white_circle", "\9898"),+      ("black_circle", "\9899"),+      -- Other+      ("ghost", "\128123"),+      ("alien", "\128125"),+      ("robot", "\129302"),+      ("skull", "\128128"),+      ("zzz", "\128164")+    ]
− Text/MMark/Extension/FontAwesome.hs
@@ -1,62 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE QuasiQuotes #-}---- |--- Module      :  Text.MMark.Extension.FontAwesome--- Copyright   :  © 2017–present Mark Karpov--- License     :  BSD 3 clause------ Maintainer  :  Mark Karpov <markkarpov92@gmail.com>--- Stability   :  experimental--- Portability :  portable------ Turn links into Font Awesome icons.-module Text.MMark.Extension.FontAwesome-  ( fontAwesome,-  )-where--import qualified Data.Text as T-import Lens.Micro ((^.))-import Lucid-import Text.MMark.Extension (Extension, Inline (..))-import qualified Text.MMark.Extension as Ext-import qualified Text.URI as URI-import Text.URI.Lens (uriPath)-import Text.URI.QQ (scheme)---- | Insert @span@s with font awesome icons using autolinks like this:------ > <fa:user>------ This @user@ identifier is the name of the icon you want to insert. You--- can also control the size of the icon like this:------ > <fa:user/fw> -- fixed width--- > <fa:user/lg> -- large--- > <fa:user/2x>--- > <fa:user/3x>--- > <fa:user/4x>--- > <fa:user/5x>------ In general, all path components that go after the name of the icon will--- be prefixed with @\"fa-\"@ and added as classes, so you can do a lot of--- fancy stuff, see <http://fontawesome.io/examples/>:------ > <fa:quote-left/3x/pull-left/border>------ See also: <http://fontawesome.io>.-fontAwesome :: Extension-fontAwesome = Ext.inlineRender $ \old inline ->-  case inline of-    l@(Link _ uri _) ->-      if URI.uriScheme uri == Just [scheme|fa|]-        then case uri ^. uriPath of-          [] -> old l-          xs ->-            let g x = "fa-" <> URI.unRText x-             in span_-                  [(class_ . T.intercalate " ") ("fa" : fmap g xs)]-                  ""-        else old l-    other -> old other
Text/MMark/Extension/Footnotes.hs view
@@ -1,5 +1,7 @@+{-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE RecordWildCards #-}  -- | -- Module      :  Text.MMark.Extension.Footnotes@@ -14,24 +16,40 @@ -- -- @since 0.1.1.0 module Text.MMark.Extension.Footnotes-  ( footnotes,+  ( -- * Rendering+    footnotes,++    -- * Validation+    Footnotes,+    footnoteScanner,+    validateFootnotes,   ) where +import Control.Foldl qualified as L import Control.Monad import Data.Char (isDigit)+import Data.List (sort) import Data.List.NonEmpty (NonEmpty (..))-import qualified Data.List.NonEmpty as NE+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as M import Data.Text (Text)-import qualified Data.Text as T+import Data.Text qualified as T import Lens.Micro ((^.)) import Lucid-import Text.MMark.Extension (Block (..), Extension, Inline (..), getOis)-import qualified Text.MMark.Extension as Ext-import qualified Text.URI as URI+import Text.MMark qualified as MMark+import Text.MMark.Render (RenderExtension, getOis)+import Text.MMark.Render qualified as Render+import Text.MMark.Trans (Block (..), Bni, Inline (..), Span (..), Trans)+import Text.MMark.Trans qualified as Trans+import Text.URI qualified as URI import Text.URI.Lens (uriPath) import Text.URI.QQ (scheme) +----------------------------------------------------------------------------+-- Rendering+ -- | The extension performs two transformations: -- --     * It turns links with URIs with @footnote@ scheme and single path@@ -42,46 +60,35 @@ -- > Here goes some text [1](footnote:1). -- > -- > > footnotes--- >--- >   1. Here we have the footnote.+-- > >+-- > > 1. Here we have the footnote. ----- The extension is not fully safe though in the sense that we can't check--- that a footnote reference refers to an existing footnote and that--- footnotes have the corresponding references, or that they are present in--- the document in the right order.-footnotes :: Extension+-- This extension only renders footnotes, it does not check that they make+-- sense. Pair it with 'validateFootnotes', which does.+footnotes :: RenderExtension footnotes = footnoteRefs <> footnoteSection  -- | Create footnote references.-footnoteRefs :: Extension-footnoteRefs = Ext.inlineRender $ \old inline ->+footnoteRefs :: RenderExtension+footnoteRefs = Render.inlineRender $ \old inline ->   case inline of-    l@(Link _ uri _) ->-      if URI.uriScheme uri == Just [scheme|footnote|]-        then case uri ^. uriPath of-          [x'] ->-            let x = URI.unRText x'-             in if T.all isDigit x-                  then-                    a_-                      [ fragmentHref (footnoteId x),-                        id_ (referenceId x)-                      ]-                      $ sup_ (toHtml x)-                  else old l-          _ -> old l-        else old l+    l@(Link _ _ uri _) ->+      case footnoteRef uri of+        Just n ->+          let x = renderIx n+           in a_ [fragmentHref (footnoteId x), id_ (referenceId x)] $+                sup_ (toHtml x)+        Nothing -> old l     other -> old other  -- | Create a footnote section.-footnoteSection :: Extension-footnoteSection = Ext.blockRender $ \old block ->+footnoteSection :: RenderExtension+footnoteSection = Render.blockRender $ \old block ->   case block of-    b@(Blockquote [Paragraph (pOis, _), OrderedList i items]) ->-      if getOis pOis == Plain "footnotes" :| []+    b@(Blockquote _ [Paragraph _ (pOis, _), OrderedList _ i items]) ->+      if Render.asPlainText (getOis pOis) == footnoteLabel         then do           let startIndex = [start_ (renderIx i) | i /= 1]-              renderIx = T.pack . show           ol_ startIndex $ do             newline             forM_ (NE.zip (NE.iterate (+ 1) i) items) $ \(j, x) -> do@@ -97,8 +104,131 @@   where     newline = "\n" +----------------------------------------------------------------------------+-- Validation++-- | The footnotes of a document as collected by 'footnoteScanner'.+data Footnotes = Footnotes+  { -- | Span of every footnote section that was found, in order+    fnSections :: [Span],+    -- | Span of every footnote, by the number it is given+    fnDefined :: Map Word Span,+    -- | Span of every reference, by the number it refers to+    fnReferenced :: Map Word [Span],+    -- | Span of every reference we could not make sense of+    fnMalformed :: [Span]+  }++instance Semigroup Footnotes where+  x <> y =+    Footnotes+      { fnSections = fnSections x <> fnSections y,+        fnDefined = fnDefined x <> fnDefined y,+        fnReferenced = M.unionWith (<>) (fnReferenced x) (fnReferenced y),+        fnMalformed = fnMalformed x <> fnMalformed y+      }++instance Monoid Footnotes where+  mempty = Footnotes [] M.empty M.empty []++-- | Collect the footnotes of a document and the references to them, so that+-- 'validateFootnotes' can check that the two agree.+footnoteScanner :: L.Fold Bni Footnotes+footnoteScanner = MMark.scanner mempty $ \acc block ->+  acc <> scanSection block <> foldMap scanInlines block++-- | A check that reports every footnote that does not make sense. Every+-- problem is reported where it can be seen: a reference that leads nowhere+-- at the reference, a footnote that nothing refers to at the footnote.+--+-- > let fns = MMark.runScanner footnoteScanner doc+-- > case MMark.runCheck (validateFootnotes fns) doc of+-- >   Left errs -> putStrLn (errorBundlePretty errs)+-- >   Right () -> …+validateFootnotes :: Footnotes -> Trans ()+validateFootnotes Footnotes {..} = do+  forM_ (drop 1 fnSections) $ \spn ->+    Trans.report spn "there is more than one footnote section"+  forM_ fnMalformed $ \spn ->+    Trans.report+      spn+      "a footnote reference must have a single number as its path"+  forM_ (M.toAscList fnReferenced) $ \(n, spns) ->+    if M.member n fnDefined+      then forM_ (drop 1 (sort spns)) $ \spn ->+        Trans.report+          spn+          ( "footnote "+              <> renderIx n+              <> " is referred to more than once, which would give the"+              <> " references the same id"+          )+      else forM_ spns $ \spn ->+        Trans.report spn ("there is no footnote " <> renderIx n)+  forM_ (M.toAscList fnDefined) $ \(n, spn) ->+    unless (M.member n fnReferenced) $+      Trans.report spn ("nothing refers to footnote " <> renderIx n)++-- | Collect a footnote section, if this block is one.+scanSection :: Bni -> Footnotes+scanSection = \case+  Blockquote spn [Paragraph _ pInlines, OrderedList _ i items]+    | Trans.asPlainText pInlines == footnoteLabel ->+        mempty+          { fnSections = [spn],+            fnDefined = M.fromList (zip [i ..] (itemSpan <$> NE.toList items))+          }+  _ -> mempty+  where+    itemSpan = \case+      [] -> Span 0 0+      xs -> foldr1 Trans.spanUnion (Trans.blockSpan <$> xs)++-- | Collect the footnote references of a collection of inlines.+scanInlines :: NonEmpty Inline -> Footnotes+scanInlines = foldMap go+  where+    go = \case+      l@(Link spn inner uri _)+        | URI.uriScheme uri == Just [scheme|footnote|] ->+            case footnoteRef uri of+              Just n -> mempty {fnReferenced = M.singleton n [spn]}+              Nothing -> mempty {fnMalformed = [Trans.inlineSpan l]}+        | otherwise -> foldMap go inner+      Emphasis _ xs -> foldMap go xs+      Strong _ xs -> foldMap go xs+      Strikeout _ xs -> foldMap go xs+      Subscript _ xs -> foldMap go xs+      Superscript _ xs -> foldMap go xs+      Image _ xs _ _ -> foldMap go xs+      _ -> mempty++----------------------------------------------------------------------------+-- Helpers++-- | The number a footnote URI refers to, if it is a well-formed footnote+-- reference.+footnoteRef :: URI.URI -> Maybe Word+footnoteRef uri =+  if URI.uriScheme uri == Just [scheme|footnote|]+    then case uri ^. uriPath of+      [x'] ->+        let x = URI.unRText x'+         in if not (T.null x) && T.all isDigit x+              then Just (read (T.unpack x))+              else Nothing+      _ -> Nothing+    else Nothing++-- | The label that marks a block quote as the footnote section.+footnoteLabel :: Text+footnoteLabel = "footnotes"++renderIx :: Word -> Text+renderIx = T.pack . show+ fragmentHref :: Text -> Attribute-fragmentHref = href_ . URI.render . Ext.headerFragment+fragmentHref = href_ . URI.render . Render.headerFragment  footnoteId :: Text -> Text footnoteId x = "fn" <> x
Text/MMark/Extension/GhcSyntaxHighlighter.hs view
@@ -18,12 +18,14 @@   ) where +import Control.Monad (forM_) import Data.Text (Text)-import qualified Data.Text as T+import Data.Text qualified as T import GHC.SyntaxHighlighter import Lucid-import Text.MMark.Extension (Block (..), Extension)-import qualified Text.MMark.Extension as Ext+import Text.MMark.Extension.Internal (infoStringParts, withLineHighlight)+import Text.MMark.Render (Block (..), RenderExtension)+import Text.MMark.Render qualified as Ext  -- | Use the @ghc-syntax-highlighter@ package to highlight Haskell code. The -- extension is applied only to code blocks with the info string@@ -49,26 +51,56 @@ -- To use with 'Text.MMark.Extension.Skylighting.skylighting' the extension -- should be applied /after/ the -- 'Text.MMark.Extension.Skylighting.skylighting' extension so it can--- overwrite its logic for code block with @\"haskell\"@ info string. So--- place it on the left hand side of @('<>')@ or above+-- overwrite its logic for a code block with the @\"haskell\"@ info string.+-- So place it on the left hand side of @('<>')@ or above -- 'Text.MMark.Extension.Skylighting.skylighting' in the list passed to -- 'Text.MMark.useExtensions'.-ghcSyntaxHighlighter :: Extension+--+-- The info string may end with a line specification, as in @haskell {2,4-6}@+-- (see 'Text.MMark.Extension.LineHighlight.lineHighlight'). It does not stop+-- the block from being recognized as Haskell, and the lines it names are+-- given the class @\"highlighted-line\"@ around the tokens of the line.+ghcSyntaxHighlighter :: RenderExtension ghcSyntaxHighlighter = Ext.blockRender $ \old block ->   case block of-    cb@(CodeBlock (Just "haskell") txt) ->-      case tokenizeHaskell txt of-        Nothing -> old cb-        Just toks -> do-          div_ [class_ "source-code"]-            . pre_-            . code_ [class_ "language-haskell"]-            $ mapM_ tokenToHtml toks-          newline+    cb@(CodeBlock _ (Just infoString) txt)+      | (Just "haskell", highlighted) <- infoStringParts infoString ->+          case tokenizeHaskell txt of+            Nothing -> old cb+            Just toks -> do+              div_ [class_ "source-code"]+                . pre_+                . code_ [class_ "language-haskell"]+                $ if null highlighted+                  then mapM_ tokenToHtml toks+                  else forM_ (zip [1 ..] (tokenLines toks)) $ \(n, l) ->+                    withLineHighlight highlighted n $ do+                      mapM_ tokenToHtml l+                      newline+              newline     other -> old other   where     newline :: Html ()     newline = "\n"++-- | Split a token stream into the tokens of each line.+tokenLines :: [(Token, Text)] -> [[(Token, Text)]]+tokenLines = dropFinalEmpty . go []+  where+    dropFinalEmpty ls = case ls of+      (_ : _) | null (last ls) -> init ls+      _ -> ls+    go acc [] = [reverse acc]+    go acc ((tt, txt) : rest) =+      case T.splitOn "\n" txt of+        [] -> go acc rest+        [only] -> go (push tt only acc) rest+        (first : more) ->+          reverse (push tt first acc)+            : fmap (\m -> push tt m []) (init more)+              <> go (push tt (last more) []) rest+    -- an empty piece is not a token, it is where a newline was+    push tt t acc = if T.null t then acc else (tt, t) : acc  -- | Render a single 'Token'. tokenToHtml :: (Token, Text) -> Html ()
+ Text/MMark/Extension/Heading.hs view
@@ -0,0 +1,102 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      :  Text.MMark.Extension.Heading+-- Copyright   :  © 2026–present Mark Karpov+-- License     :  BSD 3 clause+--+-- Maintainer  :  Mark Karpov <markkarpov92@gmail.com>+-- Stability   :  experimental+-- Portability :  portable+--+-- Checks on the headings of a document, which a parser cannot make on its+-- own because they concern the document as a whole: the outline the+-- headings form, and the ids they are given.+--+-- Scan the document first, then check what the scan collected:+--+-- > let hs = MMark.runScanner headingScanner doc+-- > MMark.runCheck (checkHeadings hs) doc+--+-- @since 0.3.0.0+module Text.MMark.Extension.Heading+  ( Headings,+    headingScanner,+    checkHeadings,+    headingProblems,+  )+where++import Control.Foldl qualified as L+import Data.List (sortOn)+import Data.Map.Strict qualified as M+import Data.Text (Text)+import Data.Text qualified as T+import Text.MMark qualified as MMark+import Text.MMark.Trans (Block (..), Bni, Span, Trans)+import Text.MMark.Trans qualified as Trans++-- | The headings of a document as collected by 'headingScanner'.+newtype Headings = Headings [(Span, Int, Text)]++instance Semigroup Headings where+  Headings x <> Headings y = Headings (x <> y)++instance Monoid Headings where+  mempty = Headings []++-- | Collect the headings of a document in the order they appear, with the+-- id each of them is given.+headingScanner :: L.Fold Bni Headings+headingScanner = MMark.scanner mempty $ \acc block ->+  acc <> heading block+  where+    heading b = case b of+      Heading1 spn x -> one spn 1 x+      Heading2 spn x -> one spn 2 x+      Heading3 spn x -> one spn 3 x+      Heading4 spn x -> one spn 4 x+      Heading5 spn x -> one spn 5 x+      Heading6 spn x -> one spn 6 x+      _ -> mempty+    one spn n x = Headings [(spn, n, Trans.headerId x)]++-- | A check that reports the problems 'headingProblems' finds.+checkHeadings :: Headings -> Trans ()+checkHeadings = mapM_ (uncurry Trans.report) . headingProblems++-- | The problems with the headings of a document:+--+--     * a heading that skips a level, such as a level 3 heading that+--       follows a level 1 one, which leaves a hole in the outline that+--       assistive technology relies on;+--     * a second level 1 heading, since a document has one title;+--     * two headings that MMark gives the same id, in which case every+--       link to one of them leads to the first.+headingProblems :: Headings -> [(Span, Text)]+headingProblems (Headings hs) =+  sortOn fst (skips <> extraTitles <> collisions)+  where+    skips =+      [ (spn, skipMessage prev n)+      | ((_, prev, _), (spn, n, _)) <- zip hs (drop 1 hs),+        n > prev + 1+      ]+    skipMessage prev n =+      "this heading is of level "+        <> tshow n+        <> ", but the one before it is of level "+        <> tshow prev+        <> ", so the outline of the document skips a level"+    extraTitles =+      [ (spn, "there is more than one level 1 heading in this document")+      | (spn, _, _) <- drop 1 [h | h@(_, 1, _) <- hs]+      ]+    collisions =+      [ (spn, "another heading is already given the id \"" <> i <> "\"")+      | (spn, _, i) <- hs,+        M.lookup i firstWithId /= Just spn+      ]+    firstWithId = M.fromListWith (\_ old -> old) [(i, spn) | (spn, _, i) <- hs]+    tshow :: Int -> Text+    tshow = T.pack . show
+ Text/MMark/Extension/Icons.hs view
@@ -0,0 +1,190 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++-- |+-- Module      :  Text.MMark.Extension.Icons+-- Copyright   :  © 2026–present Mark Karpov+-- License     :  BSD 3 clause+--+-- Maintainer  :  Mark Karpov <markkarpov92@gmail.com>+-- Stability   :  experimental+-- Portability :  portable+--+-- Put an icon in a document by naming it: @\<icon:github\>@.+--+-- The icons are yours. You give 'icons' a table that says what each name+-- draws, and the SVG it finds there goes into the page:+--+-- > myIcons :: Map Text (Html ())+-- > myIcons = toHtmlRaw <$> M.fromList+-- >   [ ("github", "<svg viewBox=\"0 0 24 24\">…</svg>")+-- >   , ("envelope", "<svg viewBox=\"0 0 24 24\">…</svg>")+-- >   ]+--+-- Nothing else about the icons is this extension's business, so any SVG+-- will do, whoever drew it. The sets people usually take them from, with+-- the licence each one puts on its artwork:+--+--     * Font Awesome Free (CC BY 4.0), the largest of them+--     * Lucide (ISC) and Feather (MIT), which it forked from, both drawn as+--       strokes on a 24×24 grid+--     * Bootstrap Icons (MIT), Heroicons (MIT), Tabler Icons (MIT), and+--       Phosphor (MIT)+--     * Octicons (MIT), the ones GitHub uses+--     * Material Symbols (Apache 2.0)+--     * Simple Icons (CC0), for the logos of companies and projects, which+--       the general-purpose sets mostly do not carry+--+-- Or draw your own, export one from a design tool, or build it with the+-- Lucid combinators instead of pasting the markup: the table holds+-- @'Html' ()@, so it does not care where the SVG came from.+--+-- This package ships no icons of its own, because bundling artwork would+-- put someone else's licence and attribution on top of its own. Whichever+-- set you take from, check what its licence asks of you; the CC BY ones+-- want to be credited somewhere in your page.+--+-- @since 0.3.0.0+module Text.MMark.Extension.Icons+  ( -- * Rendering+    icons,+    iconsWith,++    -- * Checking+    checkIcons,+    checkIconsWith,+  )+where++import Data.List.NonEmpty (NonEmpty (..))+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as M+import Data.Text (Text)+import Data.Text qualified as T+import Lucid+import Lucid.Base (makeAttribute)+import Text.MMark.Extension.Internal (inlinesOf)+import Text.MMark.Render (Inline (..), RenderExtension)+import Text.MMark.Render qualified as Render+import Text.MMark.Trans (Bni, Trans)+import Text.MMark.Trans qualified as Trans+import Text.URI (RText, RTextLabel (..), URI)+import Text.URI qualified as URI+import Text.URI.QQ (scheme)++-- | Put the SVG of an icon in place of every link with the @icon@ scheme+-- that names one:+--+-- > <icon:github>+--+-- becomes, given an @icon-github@ table entry:+--+-- > <span class="icon icon-github" aria-hidden="true">…the SVG…</span>+--+-- An icon written as an autolink is decorative: it is hidden from a screen+-- reader, which is what you want next to text that already says what the+-- link is. Give the link text instead to label it:+--+-- > [GitHub](icon:github)+--+-- > <span class="icon icon-github" role="img" aria-label="GitHub">…the SVG…</span>+--+-- Path components after the name become classes too, so an icon can be+-- given a size or a position by a style sheet of yours:+--+-- > <icon:github/lg>+--+-- > <span class="icon icon-github icon-lg" aria-hidden="true">…the SVG…</span>+--+-- A link that names an icon you do not have is left as it is, so that it is+-- visible in the output rather than missing from it. 'checkIcons' turns it+-- into an error instead.+icons ::+  -- | The icons you have, by name+  Map Text (Html ()) ->+  RenderExtension+icons = iconsWith [scheme|icon|] "icon"++-- | Like 'icons', but you choose the scheme that marks an icon and the+-- prefix of the classes. Documents written for the @fontAwesome@ extension+-- keep working with+--+-- > iconsWith [scheme|fa|] "icon" myIcons+iconsWith ::+  -- | Scheme that marks a link as an icon+  RText 'Scheme ->+  -- | Prefix of the classes to give the icon+  Text ->+  -- | The icons you have, by name+  Map Text (Html ()) ->+  RenderExtension+iconsWith scm prefix table = Render.inlineRender $ \old inline ->+  case inline of+    Link _ inner uri _+      | hasScheme scm uri,+        Just (name, mods) <- iconPath uri,+        Just svg <- M.lookup name table ->+          span_ (class_ (classes name mods) : how inner uri) svg+    other -> old other+  where+    classes name mods = T.unwords (prefix : fmap dashed (name : mods))+    dashed x = prefix <> "-" <> x+    -- An autolink is a link whose text is its own URI, and it is the way to+    -- ask for an icon that says nothing.+    how inner uri =+      let label = Render.asPlainText inner+       in if label == URI.render uri+            then [makeAttribute "aria-hidden" "true"]+            else [makeAttribute "role" "img", makeAttribute "aria-label" label]++-- | Report every link with the @icon@ scheme that does not name one of the+-- icons you have. 'icons' cannot do this itself: it runs while the document+-- is rendered, and by then there is nothing left to report against.+--+-- > MMark.runTrans (checkIcons myIcons) doc+--+-- Only the names matter here, so the table you render with will do.+checkIcons ::+  -- | The icons you have, by name+  Map Text a ->+  Bni ->+  Trans Bni+checkIcons = checkIconsWith [scheme|icon|]++-- | Like 'checkIcons', but you choose the scheme, as in 'iconsWith'.+checkIconsWith ::+  -- | Scheme that marks a link as an icon+  RText 'Scheme ->+  -- | The icons you have, by name+  Map Text a ->+  Bni ->+  Trans Bni+checkIconsWith scm table block = do+  mapM_ check (iconLinks block)+  return block+  where+    iconLinks = foldMap ofInline . inlinesOf+    ofInline = \case+      Link spn _ uri _ | hasScheme scm uri -> [(spn, uri)]+      _ -> []+    check (spn, uri) = case iconPath uri of+      Nothing -> Trans.report spn "this link names no icon"+      Just (name, _)+        | M.member name table -> return ()+        | otherwise ->+            Trans.report spn ("there is no icon called \"" <> name <> "\"")++----------------------------------------------------------------------------+-- Helpers++-- | Whether a URI is written in the given scheme.+hasScheme :: RText 'Scheme -> URI -> Bool+hasScheme scm uri = URI.uriScheme uri == Just scm++-- | The icon a URI names and the modifiers that follow it.+iconPath :: URI -> Maybe (Text, [Text])+iconPath uri = case URI.uriPath uri of+  Just (_, name :| mods) -> Just (URI.unRText name, URI.unRText <$> mods)+  Nothing -> Nothing
+ Text/MMark/Extension/Image.hs view
@@ -0,0 +1,188 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      :  Text.MMark.Extension.Image+-- Copyright   :  © 2026–present Mark Karpov+-- License     :  BSD 3 clause+--+-- Maintainer  :  Mark Karpov <markkarpov92@gmail.com>+-- Stability   :  experimental+-- Portability :  portable+--+-- Tell the browser how large an image is before it has been fetched, let it+-- decide when to fetch it, and say when an image describes itself to nobody.+--+-- An @\<img\>@ without @width@ and @height@ makes the page move under the+-- reader while the image loads, which is the layout shift every measure of+-- page quality penalizes.+--+-- 'lazyImages' and 'checkAltText' need nothing but the document. The width+-- and height have to be measured first, which 'imageScanner',+-- 'imageSizeOf', and 'imageDimensions' do between them.+--+-- @since 0.3.0.0+module Text.MMark.Extension.Image+  ( lazyImages,+    checkAltText,+    imageScanner,+    imageDimensions,+    imageSizeOf,+  )+where++import Control.Exception (IOException, try)+import Control.Foldl qualified as L+import Data.Bits (shiftL, (.|.))+import Data.ByteString qualified as B+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as M+import Data.Text qualified as T+import Data.Word (Word8)+import Lucid+import Lucid.Base (makeAttribute)+import System.IO (IOMode (..), withBinaryFile)+import Text.MMark qualified as MMark+import Text.MMark.Extension.Internal (inlinesOf)+import Text.MMark.Render (Bni, Inline (..), RenderExtension, Span)+import Text.MMark.Render qualified as Render+import Text.MMark.Trans (Trans)+import Text.MMark.Trans qualified as Trans+import Text.URI (URI)++-- | Give every image @loading=\"lazy\"@ and @decoding=\"async\"@, so that+-- an image far down the page does not hold up the ones the reader can see.+lazyImages :: RenderExtension+lazyImages = Render.inlineRender $ \old inline ->+  case inline of+    i@Image {} ->+      with+        (old i)+        [ makeAttribute "loading" "lazy",+          makeAttribute "decoding" "async"+        ]+    other -> old other++-- | Report every image whose description is empty. A reader who cannot see+-- the image is told nothing about it, and a search engine cannot index it.+--+-- Note that MMark renders such an image as @\<img alt src=\"…\"\>@ without+-- complaining, so nothing else in the pipeline will tell you.+checkAltText :: Bni -> Trans Bni+checkAltText block = do+  mapM_ check (inlinesOf block)+  return block+  where+    check = \case+      Image spn desc _ _+        | Trans.asPlainText desc == "" ->+            Trans.report spn "this image has no description for the alt attribute"+      _ -> return ()++-- | Collect the URI of every image of a document, by the span of the image+-- it belongs to.+--+-- > let imgs = MMark.runScanner imageScanner doc+-- > sizes <- traverse (imageSizeOf . toPath) imgs+-- > TL.putStr (renderText (MMark.render (imageDimensions sizes) doc))+imageScanner :: L.Fold Bni (Map Span URI)+imageScanner = MMark.scanner M.empty $ \acc block ->+  foldr insert acc (inlinesOf block)+  where+    insert = \case+      Image spn _ uri _ -> M.insert spn uri+      _ -> id++-- | Give each image the width and height it was measured to have. An image+-- with no measurement, or one that could not be measured, is left alone.+imageDimensions :: Map Span (Maybe (Int, Int)) -> RenderExtension+imageDimensions sizes = Render.inlineRender $ \old inline ->+  case inline of+    i@(Image spn _ _ _) ->+      case M.lookup spn sizes of+        Just (Just (w, h)) ->+          with (old i) [width_ (tshow w), height_ (tshow h)]+        _ -> old i+    other -> old other+  where+    tshow = T.pack . show++-- | Measure a PNG, GIF, or JPEG file without decoding it, by reading the+-- header that states its size. Anything else gives 'Nothing'.+imageSizeOf :: FilePath -> IO (Maybe (Int, Int))+imageSizeOf path = do+  r <- try (withBinaryFile path ReadMode (`B.hGet` headerLimit))+  return $ case r of+    Left (_ :: IOException) -> Nothing+    Right bs -> sizeOfPng bs `orElse` sizeOfGif bs `orElse` sizeOfJpeg bs+  where+    orElse (Just x) _ = Just x+    orElse Nothing y = y++-- | How much of a file 'imageSizeOf' reads looking for the header that+-- states its size.+headerLimit :: Int+headerLimit = 256 * 1024++-- | @IHDR@ holds the size in the first two big-endian words of its data.+sizeOfPng :: B.ByteString -> Maybe (Int, Int)+sizeOfPng bs+  | B.take 8 bs == B.pack [137, 80, 78, 71, 13, 10, 26, 10],+    B.length bs >= 24 =+      Just (be32 (B.drop 16 bs), be32 (B.drop 20 bs))+  | otherwise = Nothing++-- | The logical screen descriptor holds the size in little-endian shorts.+sizeOfGif :: B.ByteString -> Maybe (Int, Int)+sizeOfGif bs+  | B.take 3 bs == "GIF",+    B.length bs >= 10 =+      Just (le16 (B.drop 6 bs), le16 (B.drop 8 bs))+  | otherwise = Nothing++-- | Walk the segments of a JPEG until one of the frame headers, which+-- carries the size after a byte of precision.+sizeOfJpeg :: B.ByteString -> Maybe (Int, Int)+sizeOfJpeg bs+  | B.take 2 bs == B.pack [0xFF, 0xD8] = go (B.drop 2 bs)+  | otherwise = Nothing+  where+    go s = do+      (marker, rest) <- segment s+      if isFrame marker+        then+          if B.length rest >= 7+            then Just (be16 (B.drop 5 rest), be16 (B.drop 3 rest))+            else Nothing+        else+          if isStandalone marker+            then if marker == 0xD9 then Nothing else go rest+            else+              if B.length rest >= 2+                then go (B.drop (be16 rest) rest)+                else Nothing+    segment s =+      let s' = B.dropWhile (== 0xFF) s+       in if B.null s' then Nothing else Just (B.head s', B.drop 1 s')+    -- SOF0 through SOF15, less the four markers that are not frames+    isFrame m =+      m >= 0xC0 && m <= 0xCF && m /= 0xC4 && m /= 0xC8 && m /= 0xCC++-- | Whether a JPEG marker carries no payload, in which case the two bytes+-- that follow it are not a length: TEM, the eight restart markers, SOI, and+-- EOI.+isStandalone :: Word8 -> Bool+isStandalone m = m == 0x01 || (m >= 0xD0 && m <= 0xD9)++be32 :: B.ByteString -> Int+be32 b =+  (fromIntegral (B.index b 0) `shiftL` 24)+    .|. (fromIntegral (B.index b 1) `shiftL` 16)+    .|. (fromIntegral (B.index b 2) `shiftL` 8)+    .|. fromIntegral (B.index b 3)++be16 :: B.ByteString -> Int+be16 b = (fromIntegral (B.index b 0) `shiftL` 8) .|. fromIntegral (B.index b 1)++le16 :: B.ByteString -> Int+le16 b = (fromIntegral (B.index b 1) `shiftL` 8) .|. fromIntegral (B.index b 0)
+ Text/MMark/Extension/Internal.hs view
@@ -0,0 +1,98 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      :  Text.MMark.Extension.Internal+-- Copyright   :  © 2026–present Mark Karpov+-- License     :  BSD 3 clause+--+-- Maintainer  :  Mark Karpov <markkarpov92@gmail.com>+-- Stability   :  experimental+-- Portability :  portable+--+-- Helpers shared by the extensions of this package.+module Text.MMark.Extension.Internal+  ( inlinesOf,+    lineSpec,+    infoStringParts,+    withLineHighlight,+  )+where++import Data.Char (isDigit)+import Data.List.NonEmpty qualified as NE+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import Data.Text qualified as T+import Lucid+import Text.MMark.Trans (Bni, Inline (..))++-- | Every inline of a block, including the ones nested inside other+-- inlines and inside the blocks the block contains.+inlinesOf :: Bni -> [Inline]+inlinesOf = foldMap (concatMap go . NE.toList)+  where+    go i =+      i : case i of+        Emphasis _ xs -> nested xs+        Strong _ xs -> nested xs+        Strikeout _ xs -> nested xs+        Subscript _ xs -> nested xs+        Superscript _ xs -> nested xs+        Link _ xs _ _ -> nested xs+        Image _ xs _ _ -> nested xs+        _ -> []+    nested = concatMap go . NE.toList++-- | Split the info string of a code block into the language it names and+-- the lines it points at, as in @haskell {2,4-6}@.+--+-- Gives 'Nothing' when there is no line specification, so that a code block+-- written the usual way is left to whatever renders it.+lineSpec :: Text -> Maybe (Maybe Text, [Int])+lineSpec info = do+  let (before, rest) = T.breakOn "{" info+  spec <- T.stripSuffix "}" =<< T.stripPrefix "{" rest+  ns <- traverse range (T.splitOn "," (T.filter (/= ' ') spec))+  return (language before, concat ns)+  where+    range t = case T.splitOn "-" t of+      [a] -> (: []) <$> number a+      [a, b] -> do+        x <- number a+        y <- number b+        if x <= y then Just [x .. y] else Nothing+      _ -> Nothing+    number t =+      if not (T.null t) && T.all isDigit t+        then Just (read (T.unpack t))+        else Nothing++-- | Like 'lineSpec', but for an info string that need not carry a line+-- specification at all: one that does not simply points at no lines.+--+-- Every extension that renders a code block goes through this, so that a+-- language followed by a line specification is still recognized as that+-- language. Without it @haskell {2}@ looks like the name of a language+-- nobody has, and the block loses its syntax highlighting.+infoStringParts :: Text -> (Maybe Text, [Int])+infoStringParts info = fromMaybe (language info, []) (lineSpec info)++-- | Wrap the rendering of one line of a code block when the line is among+-- the ones pointed at.+withLineHighlight ::+  -- | The lines pointed at+  [Int] ->+  -- | The line being rendered, counting from one+  Int ->+  Html () ->+  Html ()+withLineHighlight ns n+  | n `elem` ns = span_ [class_ "highlighted-line"]+  | otherwise = id++-- | The language an info string names, if it names one.+language :: Text -> Maybe Text+language t =+  let l = T.strip t+   in if T.null l then Nothing else Just l
Text/MMark/Extension/Kbd.hs view
@@ -16,9 +16,9 @@ where  import Lucid-import Text.MMark.Extension (Extension, Inline (..))-import qualified Text.MMark.Extension as Ext-import qualified Text.URI as URI+import Text.MMark.Render (Inline (..), RenderExtension)+import Text.MMark.Render qualified as Ext+import Text.URI qualified as URI import Text.URI.QQ (scheme)  -- | Introduce @kbd@ tags by wrapping content in links with @kbd@ scheme.@@ -30,13 +30,13 @@ -- > [kbd]: kbd: -- -- The use of reference-style links seems more aesthetically pleasant to me,--- but you can of course do somethnig like this instead:+-- but you can of course do something like this instead: -- -- > To enable that mode press [Ctrl+A](kbd:).-kbd :: Extension+kbd :: RenderExtension kbd = Ext.inlineRender $ \old inline ->   case inline of-    l@(Link inner uri _) ->+    l@(Link _ inner uri _) ->       if URI.uriScheme uri == Just [scheme|kbd|]         then kbd_ (mapM_ old inner)         else old l
+ Text/MMark/Extension/LineHighlight.hs view
@@ -0,0 +1,72 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      :  Text.MMark.Extension.LineHighlight+-- Copyright   :  © 2026–present Mark Karpov+-- License     :  BSD 3 clause+--+-- Maintainer  :  Mark Karpov <markkarpov92@gmail.com>+-- Stability   :  experimental+-- Portability :  portable+--+-- Point at the lines of a code block that the prose is about.+--+-- Write the lines to point at after the language in the info string:+--+-- > ```haskell {2,4-6}+-- > …+-- > ```+--+-- 'Text.MMark.Extension.Skylighting.skylighting' and+-- 'Text.MMark.Extension.GhcSyntaxHighlighter.ghcSyntaxHighlighter' read the+-- same specification and point at the lines themselves, around the tokens+-- they have coloured. Put either of them before this extension and it takes+-- the blocks whose language it knows; this one renders the rest, without+-- colouring but with the lines still pointed at.+--+-- @since 0.3.0.0+module Text.MMark.Extension.LineHighlight+  ( lineHighlight,+    parseLineSpec,+  )+where++import Data.Text (Text)+import Data.Text qualified as T+import Lucid+import Text.MMark.Extension.Internal (lineSpec, withLineHighlight)+import Text.MMark.Render (Block (..), RenderExtension)+import Text.MMark.Render qualified as Render++-- | Render a code block whose info string ends with a line specification,+-- giving the lines it names the class @\"highlighted-line\"@.+--+-- The language, if there is one, still becomes the @language-@ class of the+-- @\<code\>@ element, so this composes with a style sheet written for the+-- usual output.+lineHighlight :: RenderExtension+lineHighlight = Render.blockRender $ \old block ->+  case block of+    b@(CodeBlock _ (Just info) txt) ->+      case parseLineSpec info of+        Nothing -> old b+        Just (lang, ns) -> do+          pre_+            $ code_ (langAttr lang)+            $ mapM_ (line ns) (zip [1 :: Int ..] (T.lines txt))+          "\n"+    other -> old other+  where+    langAttr = \case+      Just l | not (T.null l) -> [class_ ("language-" <> l)]+      _ -> []+    line ns (n, t) = withLineHighlight ns n (toHtml (t <> "\n"))++-- | Split an info string into the language and the lines to point at.+-- Gives 'Nothing' when there is no line specification, so that an ordinary+-- code block is left to whatever renders it.+--+-- > parseLineSpec "haskell {2,4-6}" == Just (Just "haskell", [2,4,5,6])+parseLineSpec :: Text -> Maybe (Maybe Text, [Int])+parseLineSpec = lineSpec
+ Text/MMark/Extension/Link.hs view
@@ -0,0 +1,180 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TupleSections #-}++-- |+-- Module      :  Text.MMark.Extension.Link+-- Copyright   :  © 2018–present Mark Karpov+-- License     :  BSD 3 clause+--+-- Maintainer  :  Mark Karpov <markkarpov92@gmail.com>+-- Stability   :  experimental+-- Portability :  portable+--+-- Say where a link opens, and find the links that lead nowhere.+--+-- 'linkTarget' is the only render extension here; the rest are checks. The+-- three checks cost increasingly more, so they are separate: checking+-- fragments needs nothing but the document, checking local files needs the+-- file system, and checking the rest needs whatever you are willing to do+-- to find out.+--+-- @since 0.3.0.0+module Text.MMark.Extension.Link+  ( linkTarget,+    headerIdScanner,+    checkFragments,+    checkLocalFiles,+    checkExternal,+  )+where++import Control.Foldl qualified as L+import Control.Monad.IO.Class (liftIO)+import Data.Foldable (asum)+import Data.Maybe (fromMaybe)+import Data.Set (Set)+import Data.Set qualified as S+import Data.Text qualified as T+import Lucid+import System.Directory (doesDirectoryExist, doesFileExist)+import System.FilePath ((</>))+import Text.MMark qualified as MMark+import Text.MMark.Extension.Internal (inlinesOf)+import Text.MMark.Render (RenderExtension)+import Text.MMark.Render qualified as Render+import Text.MMark.Trans (Block (..), Bni, Inline (..), Trans, TransT)+import Text.MMark.Trans qualified as Trans+import Text.URI (URI (..))+import Text.URI qualified as URI++-- | When the title of a link starts with the word @\"_blank\"@,+-- @\"_self\"@, @\"_parent\"@, or @\"_top\"@, it's stripped from the title (as+-- well as all whitespace after it) and added as the value of the @target@+-- attribute of the resulting link.+--+-- For example:+--+-- > This [link](/url '_blank My title') opens in new tab.+--+-- A link that opens in a new browsing context also gets+-- @rel=\"noopener noreferrer\"@. Without it the page that is opened can+-- reach back to the page that opened it through @window.opener@, and the+-- referrer is disclosed to it.+linkTarget :: RenderExtension+linkTarget = Render.inlineRender $ \old inline ->+  case inline of+    l@(Link spn txt url (Just title)) -> fromMaybe (old l) $ do+      let f prefix =+            (prefix,) . T.stripStart+              <$> T.stripPrefix prefix title+      (prefix, title') <-+        asum $+          f <$> ["_blank", "_self", "_parent", "_top"]+      let mtitle = if T.null title' then Nothing else Just title'+          -- Only a new browsing context can reach back through+          -- window.opener, so the other targets do not need protecting.+          relAttrs =+            [rel_ "noopener noreferrer" | prefix == "_blank"]+      return $+        with (old (Link spn txt url mtitle)) (target_ prefix : relAttrs)+    other -> old other++-- | Collect the ids MMark gives to the headings of a document, so that+-- 'checkFragments' can tell whether a link into the document leads+-- anywhere.+headerIdScanner :: L.Fold Bni (Set T.Text)+headerIdScanner = MMark.scanner S.empty $ \acc block ->+  case block of+    Heading1 _ x -> add x acc+    Heading2 _ x -> add x acc+    Heading3 _ x -> add x acc+    Heading4 _ x -> add x acc+    Heading5 _ x -> add x acc+    Heading6 _ x -> add x acc+    _ -> acc+  where+    add x = S.insert (Trans.headerId x)++-- | Report every link of the form @#section@ whose fragment no heading of+-- the document defines.+--+-- > let ids = MMark.runScanner headerIdScanner doc+-- > MMark.runTrans (checkFragments ids) doc+checkFragments :: Set T.Text -> Bni -> Trans Bni+checkFragments ids block = do+  mapM_ check (links block)+  return block+  where+    check (spn, uri) = case internalFragment uri of+      Just f+        | not (f `S.member` ids) ->+            Trans.report+              spn+              ("no heading of this document has the id \"" <> f <> "\"")+      _ -> return ()++-- | Report every link to a path that does not exist, relative to the given+-- directory. Links with a scheme or an authority are left to+-- 'checkExternal'.+checkLocalFiles :: FilePath -> Bni -> TransT IO Bni+checkLocalFiles base block = do+  mapM_ check (links block)+  return block+  where+    check (spn, uri) = case localPath uri of+      Nothing -> return ()+      Just p -> do+        let path = base </> T.unpack p+        there <- liftIO $ (||) <$> doesFileExist path <*> doesDirectoryExist path+        if there+          then return ()+          else Trans.report spn ("there is nothing at " <> T.pack path)++-- | Report every link the given action says is unreachable. The action is+-- yours to write, so that this package needs no HTTP client of its own and+-- so that you can cache, rate limit, or skip whatever you like.+--+-- > checkExternal (\uri -> (== 200) . statusCode <$> headRequest uri)+checkExternal :: (URI -> IO Bool) -> Bni -> TransT IO Bni+checkExternal reachable block = do+  mapM_ check (links block)+  return block+  where+    check (spn, uri) =+      case (URI.uriScheme uri, localPath uri, internalFragment uri) of+        (Nothing, _, _) -> return ()+        (_, Just _, _) -> return ()+        (_, _, Just _) -> return ()+        _ -> do+          ok <- liftIO (reachable uri)+          if ok+            then return ()+            else Trans.report spn ("cannot reach " <> URI.render uri)++----------------------------------------------------------------------------+-- Helpers++-- | The links and images of a block, with the span to report against.+links :: Bni -> [(Trans.Span, URI)]+links = foldMap ofInline . inlinesOf+  where+    ofInline = \case+      Link spn _ uri _ -> [(spn, uri)]+      Image spn _ uri _ -> [(spn, uri)]+      _ -> []++-- | The fragment of a URI that points into the document it appears in.+internalFragment :: URI -> Maybe T.Text+internalFragment uri =+  case (uriScheme uri, uriAuthority uri, uriPath uri, uriFragment uri) of+    (Nothing, Left False, Nothing, Just f) -> Just (URI.unRText f)+    _ -> Nothing++-- | The path of a URI that points at a file next to the document.+localPath :: URI -> Maybe T.Text+localPath uri =+  case (uriScheme uri, uriAuthority uri, uriPath uri) of+    (Nothing, Left False, Just (_, ps)) ->+      Just (T.intercalate "/" (URI.unRText <$> foldr (:) [] ps))+    _ -> Nothing
− Text/MMark/Extension/LinkTarget.hs
@@ -1,47 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE TupleSections #-}---- |--- Module      :  Text.MMark.Extension.LinkTarget--- Copyright   :  © 2018–present Mark Karpov--- License     :  BSD 3 clause------ Maintainer  :  Mark Karpov <markkarpov92@gmail.com>--- Stability   :  experimental--- Portability :  portable------ Specify the @target@ attribute of links in link titles. This allows us--- to, e.g. make a link open in a new tab.-module Text.MMark.Extension.LinkTarget-  ( linkTarget,-  )-where--import Data.Foldable (asum)-import Data.Maybe (fromMaybe)-import qualified Data.Text as T-import Lucid-import Text.MMark.Extension (Extension, Inline (..))-import qualified Text.MMark.Extension as Ext---- | When title of a link starts with the word @\"_blank\"@, @\"_self\"@,--- @\"_parent\"@, or @\"_top\"@, it's stripped from title (as well as all--- whitespace after it) and added as the value of @target@ attribute of the--- resulting link.------ For example:------ > This [link](/url '_blank My title') opens in new tab.-linkTarget :: Extension-linkTarget = Ext.inlineRender $ \old inline ->-  case inline of-    l@(Link txt url (Just title)) -> fromMaybe (old l) $ do-      let f prefix =-            (prefix,) . T.stripStart-              <$> T.stripPrefix prefix title-      (prefix, title') <--        asum $-          f <$> ["_blank", "_self", "_parent", "_top"]-      let mtitle = if T.null title' then Nothing else Just title'-      return $ with (old (Link txt url mtitle)) [target_ prefix]-    other -> old other
Text/MMark/Extension/MathJax.hs view
@@ -19,10 +19,10 @@  import Control.Monad import Data.Text (Text)-import qualified Data.Text as T+import Data.Text qualified as T import Lucid-import Text.MMark.Extension (Block (..), Extension, Inline (..))-import qualified Text.MMark.Extension as Ext+import Text.MMark.Render (Block (..), Inline (..), RenderExtension)+import Text.MMark.Render qualified as Ext  -- | The extension allows us to transform inline code spans into MathJax -- inline spans and code blocks with the info string @\"mathjax\"@@@ -37,16 +37,16 @@ mathJax ::   -- | Starting\/ending character in MathJax inline spans   Maybe Char ->-  Extension+  RenderExtension mathJax mch = mathJaxSpan mch <> mathJaxBlock  -- | Turn code spans that start and end with a given character into MathJax -- inline spans. If 'Nothing' is provided instead of a char, apply the -- transformation to all code spans.-mathJaxSpan :: Maybe Char -> Extension+mathJaxSpan :: Maybe Char -> RenderExtension mathJaxSpan mch = Ext.inlineRender $ \old inline ->   case inline of-    s@(CodeSpan txt) ->+    s@(CodeSpan _ txt) ->       case mch of         Nothing -> spn txt         Just ch ->@@ -62,10 +62,10 @@  -- | Turn code blocks with info string @\"mathjax\"@ into MathJax display -- spans.-mathJaxBlock :: Extension+mathJaxBlock :: RenderExtension mathJaxBlock = Ext.blockRender $ \old block ->   case block of-    b@(CodeBlock mlabel txt) ->+    b@(CodeBlock _ mlabel txt) ->       if mlabel == Just "mathjax"         then do           p_ . forM_ (T.lines txt) $ \x ->
+ Text/MMark/Extension/Mermaid.hs view
@@ -0,0 +1,79 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      :  Text.MMark.Extension.Mermaid+-- Copyright   :  © 2026–present Mark Karpov+-- License     :  BSD 3 clause+--+-- Maintainer  :  Mark Karpov <markkarpov92@gmail.com>+-- Stability   :  experimental+-- Portability :  portable+--+-- Turn code blocks with the @mermaid@ info string into diagrams, either in+-- the browser or ahead of time.+--+-- @since 0.3.0.0+module Text.MMark.Extension.Mermaid+  ( -- * In the browser+    mermaid,++    -- * Ahead of time+    mermaidScanner,+    mermaidSvg,+  )+where++import Control.Foldl qualified as L+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as M+import Data.Text (Text)+import Lucid+import Text.MMark qualified as MMark+import Text.MMark.Render (Block (..), Bni, RenderExtension, Span)+import Text.MMark.Render qualified as Render++-- | Render a @mermaid@ code block as @\<pre class=\"mermaid\"\>@, which is+-- what the mermaid script in the page looks for.+mermaid :: RenderExtension+mermaid = Render.blockRender $ \old block ->+  case block of+    b@(CodeBlock _ mlabel txt) ->+      if mlabel == Just label+        then pre_ [class_ label] (toHtml txt) >> "\n"+        else old b+    other -> old other++-- | Collect the source of every @mermaid@ code block, by the span of the+-- block it came from.+--+-- Hand the result to whatever turns a diagram into an SVG, then give the+-- SVGs to 'mermaidSvg':+--+-- > srcs <- pure (MMark.runScanner mermaidScanner doc)+-- > svgs <- traverse mermaidCli srcs+-- > TL.putStr (renderText (MMark.render (mermaidSvg svgs) doc))+--+-- The span is the key because it is what tells two diagrams apart, even+-- two that contain exactly the same source.+mermaidScanner :: L.Fold Bni (Map Span Text)+mermaidScanner = MMark.scanner M.empty $ \acc block ->+  case block of+    CodeBlock spn (Just l) txt | l == label -> M.insert spn txt acc+    _ -> acc++-- | Put the given SVG in place of the @mermaid@ code block it was made+-- from. A block with no SVG is left as it is, so that a diagram that could+-- not be rendered is still visible as its source.+mermaidSvg :: Map Span Text -> RenderExtension+mermaidSvg svgs = Render.blockRender $ \old block ->+  case block of+    b@(CodeBlock spn (Just l) _)+      | l == label ->+          case M.lookup spn svgs of+            Just svg -> figure_ [class_ label] (toHtmlRaw svg) >> "\n"+            Nothing -> old b+    other -> old other++label :: Text+label = "mermaid"
+ Text/MMark/Extension/Metadata.hs view
@@ -0,0 +1,101 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++-- |+-- Module      :  Text.MMark.Extension.Metadata+-- Copyright   :  © 2026–present Mark Karpov+-- License     :  BSD 3 clause+--+-- Maintainer  :  Mark Karpov <markkarpov92@gmail.com>+-- Stability   :  experimental+-- Portability :  portable+--+-- What a blog wants to know about a post: how long it is, how long it+-- takes to read, and what to put on the card that appears when it is+-- shared.+--+-- @since 0.3.0.0+module Text.MMark.Extension.Metadata+  ( Metadata (..),+    metadataScanner,+    readingTime,+  )+where++import Control.Foldl qualified as L+import Data.Text (Text)+import Data.Text qualified as T+import Text.MMark qualified as MMark+import Text.MMark.Extension.Internal (inlinesOf)+import Text.MMark.Trans (Block (..), Bni, Inline (..))+import Text.MMark.Trans qualified as Trans+import Text.URI (URI)++-- | What 'metadataScanner' finds out about a document.+data Metadata = Metadata+  { -- | Number of words in the prose of the document+    metaWords :: !Int,+    -- | Text of the first paragraph, for the description of a card+    metaLead :: Maybe Text,+    -- | URI of the first image, for the picture on a card+    metaImage :: Maybe URI,+    -- | Text of the first level 1 heading, for the title+    metaTitle :: Maybe Text+  }+  deriving (Eq, Show)++instance Semigroup Metadata where+  x <> y =+    Metadata+      { metaWords = metaWords x + metaWords y,+        metaLead = firstOf metaLead,+        metaImage = firstOf metaImage,+        metaTitle = firstOf metaTitle+      }+    where+      firstOf f = maybe (f y) Just (f x)++instance Monoid Metadata where+  mempty = Metadata 0 Nothing Nothing Nothing++-- | Scan a document for its 'Metadata'.+--+-- > let meta = MMark.runScanner metadataScanner doc+-- > putStrLn (show (readingTime 200 meta) <> " minute read")+metadataScanner :: L.Fold Bni Metadata+metadataScanner = MMark.scanner mempty $ \acc block ->+  acc <> ofBlock block+  where+    ofBlock block =+      mempty+        { metaWords = wordsIn block,+          metaLead = leadOf block,+          metaImage = imageOf block,+          metaTitle = titleOf block+        }+    wordsIn = length . T.words . T.unwords . fmap plainOf . inlinesOf+    plainOf = \case+      Plain _ t -> t+      CodeSpan _ t -> t+      _ -> ""+    leadOf = \case+      Paragraph _ xs -> Just (Trans.asPlainText xs)+      _ -> Nothing+    titleOf = \case+      Heading1 _ xs -> Just (Trans.asPlainText xs)+      _ -> Nothing+    imageOf block = case [uri | Image _ _ uri _ <- inlinesOf block] of+      (uri : _) -> Just uri+      [] -> Nothing++-- | How many minutes the document takes to read at the given number of+-- words per minute, rounded up, and never less than one.+readingTime ::+  -- | Words per minute, 200 to 250 for most readers+  Int ->+  -- | Collected metadata+  Metadata ->+  Int+readingTime wpm Metadata {..} =+  max 1 ((metaWords + wpm - 1) `div` wpm)
− Text/MMark/Extension/ObfuscateEmail.hs
@@ -1,65 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE QuasiQuotes #-}---- |--- Module      :  Text.MMark.Extension.ObfuscateEmail--- Copyright   :  © 2018–present Mark Karpov--- License     :  BSD 3 clause------ Maintainer  :  Mark Karpov <markkarpov92@gmail.com>--- Stability   :  experimental--- Portability :  portable------ Obfuscate email addresses.-module Text.MMark.Extension.ObfuscateEmail-  ( obfuscateEmail,-  )-where--import Data.List.NonEmpty (NonEmpty (..))-import Data.Text (Text)-import qualified Data.Text as T-import Lucid-import Text.MMark.Extension (Extension, Inline (..))-import qualified Text.MMark.Extension as Ext-import qualified Text.URI as URI-import Text.URI.QQ (scheme, uri)---- | This extension makes email addresses in autolinks be rendered as--- something like this:------ > <a class="protected-email"--- >    data-email="something@example.org"--- >    href="javascript:void(0)">Enable JavaScript to see this email</a>------ You'll also need to include jQuery and this bit of JS code for the magic--- to work:------ > $(document).ready(function () {--- >     $(".protected-email").each(function () {--- >         var item = $(this);--- >         var email = item.data('email');--- >         item.attr('href', 'mailto:' + email);--- >         item.html(email);--- >     });--- > });-obfuscateEmail ::-  -- | Name of class to assign to the links, e.g. @\"protected-email\"@-  Text ->-  Extension-obfuscateEmail class' = Ext.inlineRender $ \old inline ->-  case inline of-    l@(Link _ email mtitle) ->-      if URI.uriScheme email == Just [scheme|mailto|]-        then-          let txt = Plain "Enable JavaScript to see this email" :| []-              js = [uri|javascript:void(0)|]-           in with-                (old (Link txt js mtitle))-                [ class_ class',-                  data_-                    "email"-                    (T.drop 7 (URI.render email))-                ]-        else old l-    other -> old other
+ Text/MMark/Extension/Permalinks.hs view
@@ -0,0 +1,83 @@+{-# LANGUAGE OverloadedStrings #-}++-- |+-- Module      :  Text.MMark.Extension.Permalinks+-- Copyright   :  © 2026–present Mark Karpov+-- License     :  BSD 3 clause+--+-- Maintainer  :  Mark Karpov <markkarpov92@gmail.com>+-- Stability   :  experimental+-- Portability :  portable+--+-- Give every heading a link to itself, so that a reader can get a URL that+-- points at the section they are looking at.+--+-- @since 0.3.0.0+module Text.MMark.Extension.Permalinks+  ( permalinks,+    permalinksWith,+  )+where++import Data.Text (Text)+import Lucid+import Lucid.Base (makeAttribute)+import Text.MMark.Render (Block (..), Ois, RenderExtension, getOis)+import Text.MMark.Render qualified as Render+import Text.URI qualified as URI++-- | Append to every heading a link to the id MMark gives that heading. The+-- link is labelled @\"#\"@ and given the class @\"permalink\"@, so that a+-- style sheet can show it only when the heading is hovered.+permalinks :: RenderExtension+permalinks = permalinksWith (const True) "permalink" Nothing "#"++-- | Like 'permalinks', but you choose which headings get a link, the class+-- it is given, what a screen reader makes of it, and what the reader sees.+--+-- The last of these is @'Html' ()@, so the link can be labelled with an+-- icon rather than a character:+--+-- > permalinksWith (\n -> n >= 2 && n <= 4) "anchor" Nothing linkIcon+--+-- A link nothing is to be said about is hidden from a screen reader, and+-- taken out of the order the keyboard walks: a link that is announced to+-- nobody is of no use to someone who has landed on it. Say what it is+-- instead to keep it in:+--+-- > permalinksWith (const True) "anchor" (Just "Link to this section") "#"+permalinksWith ::+  -- | Whether to give a heading of this level (1–6) a link+  (Int -> Bool) ->+  -- | Class to give the link+  Text ->+  -- | What a screen reader should say, if anything+  Maybe Text ->+  -- | What the reader sees+  Html () ->+  RenderExtension+permalinksWith p klass spoken shown = Render.blockRender $ \old block ->+  case block of+    Heading1 spn x | p 1 -> old (Heading1 spn (anchor x))+    Heading2 spn x | p 2 -> old (Heading2 spn (anchor x))+    Heading3 spn x | p 3 -> old (Heading3 spn (anchor x))+    Heading4 spn x | p 4 -> old (Heading4 spn (anchor x))+    Heading5 spn x | p 5 -> old (Heading5 spn (anchor x))+    Heading6 spn x | p 6 -> old (Heading6 spn (anchor x))+    other -> old other+  where+    anchor (ois, html) = (ois, html <> link ois)+    link :: Ois -> Html ()+    link ois =+      a_+        ( href_ (URI.render (Render.headerFragment (Render.headerId (getOis ois))))+            : class_ klass+            : how+        )+        shown+    how = case spoken of+      Just t -> [makeAttribute "aria-label" t]+      Nothing ->+        [ makeAttribute "aria-hidden" "true",+          makeAttribute "tabindex" "-1"+        ]
Text/MMark/Extension/PunctuationPrettifier.hs view
@@ -18,9 +18,9 @@  import Data.Char (isSpace) import Data.Text (Text)-import qualified Data.Text as T-import Text.MMark.Extension (Extension, Inline (..))-import qualified Text.MMark.Extension as Ext+import Data.Text qualified as T+import Text.MMark.Trans (Bni, Inline (..), Trans)+import Text.MMark.Trans qualified as Trans  -- | Prettify punctuation (only affects plain text in inlines): --@@ -32,10 +32,10 @@ --     * Replace @'@ with left single quote @‘@ when previous character was --       a space character, otherwise replace it with right single quote @’@ --       aka apostrophe-punctuationPrettifier :: Extension-punctuationPrettifier = Ext.inlineTrans $ \case-  Plain txt -> Plain (T.unfoldr gen (True, txt))-  other -> other+punctuationPrettifier :: Bni -> Trans Bni+punctuationPrettifier = Trans.bottomUpInlines $ \case+  Plain spn txt -> return (Plain spn (T.unfoldr gen (True, txt)))+  other -> return other  gen ::   -- | Whether the previous character was a space and remaining input
Text/MMark/Extension/Skylighting.hs view
@@ -18,12 +18,13 @@  import Control.Monad import Data.Text (Text)-import qualified Data.Text as T+import Data.Text qualified as T import Lucid import Skylighting (Token, TokenType (..))-import qualified Skylighting as S-import Text.MMark.Extension (Block (..), Extension)-import qualified Text.MMark.Extension as Ext+import Skylighting qualified as S+import Text.MMark.Extension.Internal (infoStringParts, withLineHighlight)+import Text.MMark.Render (Block (..), RenderExtension)+import Text.MMark.Render qualified as Ext  -- | Use the @skylighting@ package to render code blocks with info strings -- that result in a successful lookup from 'S.defaultSyntaxMap'.@@ -61,16 +62,22 @@ --     * 'VariableTok'       = @\"va\"@ --     * 'VerbatimStringTok' = @\"vs\"@ --     * 'WarningTok'        = @\"wa\"@-skylighting :: Extension+--+-- The info string may end with a line specification, as in @haskell {2,4-6}@+-- (see 'Text.MMark.Extension.LineHighlight.lineHighlight'). It does not stop+-- the language from being recognized, and the lines it names are given the+-- class @\"highlighted-line\"@ around the tokens of the line.+skylighting :: RenderExtension skylighting = Ext.blockRender $ \old block ->   case block of-    cb@(CodeBlock (Just infoString') txt) ->+    cb@(CodeBlock _ (Just infoString') txt) ->       let tokenizerConfig =             S.TokenizerConfig               { S.syntaxMap = S.defaultSyntaxMap,                 S.traceOutput = False               }-          infoString = T.replace "-" " " infoString'+          (lang, highlighted) = infoStringParts infoString'+          infoString = maybe "" (T.replace "-" " ") lang        in case S.lookupSyntax infoString S.defaultSyntaxMap of             Nothing -> old cb             Just syntax ->@@ -80,10 +87,11 @@                   div_ [class_ "source-code"]                     . pre_                     . code_ [class_ ("language-" <> infoString)]-                    . forM_ ls-                    $ \l -> do-                      mapM_ tokenToHtml l-                      newline+                    . forM_ (zip [1 ..] ls)+                    $ \(n, l) ->+                      withLineHighlight highlighted n $ do+                        mapM_ tokenToHtml l+                        newline                   newline     other -> old other   where
Text/MMark/Extension/TableOfContents.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}  -- | -- Module      :  Text.MMark.Extension.TableOfContents@@ -9,8 +10,8 @@ -- Stability   :  experimental -- Portability :  portable ----- Place this markup in markdown document where you want table of contents--- to be inserted:+-- Place this markup in a markdown document where you want a table of+-- contents to be inserted: -- -- > ```toc -- > ```@@ -24,32 +25,33 @@   ) where -import qualified Control.Foldl as L+import Control.Foldl qualified as L import Data.List.NonEmpty (NonEmpty (..))-import qualified Data.List.NonEmpty as NE+import Data.List.NonEmpty qualified as NE import Data.Maybe (maybeToList) import Data.Text (Text)-import Text.MMark.Extension (Block (..), Bni, Extension, Inline (..))-import qualified Text.MMark.Extension as Ext+import Text.MMark qualified as MMark+import Text.MMark.Trans (Block (..), Bni, Inline (..), Span, Trans)+import Text.MMark.Trans qualified as Trans --- | An opaque type representing table of contents produced by the+-- | An opaque type representing a table of contents produced by the -- 'tocScanner' scanner. newtype Toc = Toc [(Int, NonEmpty Inline)] --- | The scanner builds table of contents 'Toc' that can then be passed to+-- | The scanner builds a table of contents 'Toc' that can then be passed to -- 'toc' to obtain an extension that renders the table of contents in HTML. tocScanner ::   -- | Whether to include a header of this level (1–6)   (Int -> Bool) ->   L.Fold Bni Toc-tocScanner p = fmap (Toc . ($ [])) . Ext.scanner id $ \xs block ->+tocScanner p = fmap (Toc . ($ [])) . MMark.scanner id $ \xs block ->   case block of-    Heading1 x -> f 1 x xs-    Heading2 x -> f 2 x xs-    Heading3 x -> f 3 x xs-    Heading4 x -> f 4 x xs-    Heading5 x -> f 5 x xs-    Heading6 x -> f 6 x xs+    Heading1 _ x -> f 1 x xs+    Heading2 _ x -> f 2 x xs+    Heading3 _ x -> f 3 x xs+    Heading4 _ x -> f 4 x xs+    Heading5 _ x -> f 5 x xs+    Heading6 _ x -> f 6 x xs     _ -> xs   where     f n a as =@@ -59,31 +61,39 @@  -- | Create an extension that replaces a certain code block with the -- previously constructed table of contents.+--+-- A document that asks for a table of contents but has no headings to put+-- in one is reported at the code block that asks, because there is nothing+-- to put in its place and leaving the block alone would render the marker+-- into the page as an empty code block. toc ::   -- | Label of the code block to replace by the table of contents   Text ->   -- | Previously generated by 'tocScanner'   Toc ->-  Extension-toc label (Toc xs) = Ext.blockTrans $ \case-  old@(CodeBlock mlabel _) ->-    case NE.nonEmpty xs of-      Nothing -> old-      Just ns ->-        if mlabel == pure label-          then renderToc ns-          else old-  other -> other+  Bni ->+  Trans Bni+toc label (Toc xs) = Trans.bottomUpBlocks $ \case+  old@(CodeBlock spn mlabel _)+    | mlabel == pure label ->+        case NE.nonEmpty xs of+          Nothing -> do+            Trans.report+              spn+              "there are no headings to put in the table of contents"+            return old+          Just ns -> return (renderToc spn ns)+  other -> return other --- | Construct 'Bni' for a table of contents from given collection of+-- | Construct 'Bni' for a table of contents from a given collection of -- headers. This is a non-public helper.-renderToc :: NonEmpty (Int, NonEmpty Inline) -> Bni-renderToc = UnorderedList . NE.unfoldr f+renderToc :: Span -> NonEmpty (Int, NonEmpty Inline) -> Bni+renderToc spn = UnorderedList spn . NE.unfoldr f   where     f ((n, x) :| xs) =       let (sitems, fitems) = span ((> n) . fst) xs-          url = Ext.headerFragment (Ext.headerId x)-       in ( Naked (Link x url Nothing :| []) :-            maybeToList (renderToc <$> NE.nonEmpty sitems),+          url = Trans.headerFragment (Trans.headerId x)+       in ( Naked spn (Link spn x url Nothing :| [])+              : maybeToList (renderToc spn <$> NE.nonEmpty sitems),             NE.nonEmpty fitems           )
mmark-ext.cabal view
@@ -1,11 +1,11 @@ cabal-version:   2.4 name:            mmark-ext-version:         0.2.1.5+version:         0.3.0.0 license:         BSD-3-Clause license-file:    LICENSE.md maintainer:      Mark Karpov <markkarpov92@gmail.com> author:          Mark Karpov <markkarpov92@gmail.com>-tested-with:     ghc ==8.10.7 ghc ==9.0.2 ghc ==9.2.1+tested-with:     ghc ==9.10.3 ghc ==9.12.4 ghc ==9.14.1 homepage:        https://github.com/mmark-md/mmark-ext bug-reports:     https://github.com/mmark-md/mmark-ext/issues synopsis:        Commonly useful extensions for the MMark markdown processor@@ -35,34 +35,45 @@ library     exposed-modules:         Text.MMark.Extension.Common+        Text.MMark.Extension.Emoji+        Text.MMark.Extension.Heading+        Text.MMark.Extension.Icons+        Text.MMark.Extension.Image+        Text.MMark.Extension.LineHighlight+        Text.MMark.Extension.Link+        Text.MMark.Extension.Mermaid+        Text.MMark.Extension.Metadata+        Text.MMark.Extension.Permalinks         Text.MMark.Extension.Comment-        Text.MMark.Extension.FontAwesome         Text.MMark.Extension.Footnotes         Text.MMark.Extension.GhcSyntaxHighlighter         Text.MMark.Extension.Kbd-        Text.MMark.Extension.LinkTarget         Text.MMark.Extension.MathJax-        Text.MMark.Extension.ObfuscateEmail         Text.MMark.Extension.PunctuationPrettifier         Text.MMark.Extension.Skylighting         Text.MMark.Extension.TableOfContents -    default-language: Haskell2010+    other-modules:    Text.MMark.Extension.Internal+    default-language: GHC2021     build-depends:-        base >=4.13 && <5.0,+        base >=4.16 && <5,+        bytestring >=0.10 && <0.13,+        containers >=0.5 && <0.9,+        directory >=1.2 && <1.4,+        filepath >=1.4 && <1.6,         foldl >=1.2 && <1.5,         ghc-syntax-highlighter >=0.0.1 && <0.1,-        lucid >=2.9.13 && <3.0,-        microlens >=0.4 && <0.5,-        mmark >=0.0.4 && <=0.1,+        lucid >=2.9.13 && <3,+        microlens >=0.4 && <0.6,+        mmark >=0.1 && <0.2,         modern-uri >=0.3.4.4 && <0.4,-        skylighting >=0.7.6 && <0.13,-        text >=0.2 && <1.3+        skylighting >=0.7.6 && <0.15,+        text >=0.2 && <2.2      if flag(dev)         ghc-options:-            -O0 -Wall -Werror -Wcompat -Wincomplete-record-updates-            -Wincomplete-uni-patterns -Wnoncanonical-monad-instances+            -Wall -Werror -Wredundant-constraints -Wpartial-fields+            -Wunused-packages -haddock -Winvalid-haddock      else         ghc-options: -O2 -Wall@@ -74,30 +85,43 @@     hs-source-dirs:     tests     other-modules:         Text.MMark.Extension.CommentSpec-        Text.MMark.Extension.FontAwesomeSpec+        Text.MMark.Extension.EmojiSpec+        Text.MMark.Extension.HeadingSpec+        Text.MMark.Extension.IconsSpec+        Text.MMark.Extension.ImageSpec+        Text.MMark.Extension.LineHighlightSpec+        Text.MMark.Extension.LinkSpec+        Text.MMark.Extension.MermaidSpec+        Text.MMark.Extension.MetadataSpec+        Text.MMark.Extension.PermalinksSpec         Text.MMark.Extension.FootnotesSpec         Text.MMark.Extension.GhcSyntaxHighlighterSpec         Text.MMark.Extension.KbdSpec-        Text.MMark.Extension.LinkTargetSpec         Text.MMark.Extension.MathJaxSpec-        Text.MMark.Extension.ObfuscateEmailSpec         Text.MMark.Extension.PunctuationPrettifierSpec         Text.MMark.Extension.SkylightingSpec         Text.MMark.Extension.TableOfContentsSpec         Text.MMark.Extension.TestUtils -    default-language:   Haskell2010+    default-language:   GHC2021     build-depends:-        base >=4.13 && <5.0,-        hspec >=2.0 && <3.0,-        lucid >=2.9.13 && <3.0,-        mmark >=0.0.4 && <=0.1,+        base >=4.16 && <5,+        bytestring >=0.10 && <0.13,+        containers >=0.5 && <0.9,+        directory >=1.2 && <1.4,+        filepath >=1.4 && <1.6,+        hspec >=2 && <3,+        lucid >=2.9.13 && <3,+        megaparsec >=8 && <10,+        mmark >=0.1 && <0.2,         mmark-ext,-        skylighting >=0.7.6 && <0.13,-        text >=0.2 && <1.3+        modern-uri >=0.3.4.4 && <0.4,+        text >=0.2 && <2.2      if flag(dev)-        ghc-options: -O0 -Wall -Werror+        ghc-options:+            -Wall -Werror -Wredundant-constraints -Wpartial-fields+            -Wunused-packages -haddock -Winvalid-haddock      else         ghc-options: -O2 -Wall
tests/Text/MMark/Extension/CommentSpec.hs view
@@ -10,12 +10,12 @@ spec =   describe "commentParagraph" $ do     let to = withExt (commentParagraph "$$$")-    context "when it is the only content in document" $-      it "is removed" $-        "$$$ Here we go." `to` ""-    context "when it is intermixed with other paragraphs" $-      it "is removed" $-        "First.\n\n$$$Second.\n\nThird.\n" `to` "<p>First.</p>\n<p>Third.</p>\n"-    context "when it is not in plain text" $-      it "has no special effect" $-        "[$$$ link](/url) foo." `to` "<p><a href=\"/url\">$$$ link</a> foo.</p>\n"+    context "when it is the only content in document"+      $ it "is removed"+      $ "$$$ Here we go." `to` ""+    context "when it is intermixed with other paragraphs"+      $ it "is removed"+      $ "First.\n\n$$$Second.\n\nThird.\n" `to` "<p>First.</p>\n<p>Third.</p>\n"+    context "when it is not in plain text"+      $ it "has no special effect"+      $ "[$$$ link](/url) foo." `to` "<p><a href=\"/url\">$$$ link</a> foo.</p>\n"
+ tests/Text/MMark/Extension/EmojiSpec.hs view
@@ -0,0 +1,61 @@+{-# LANGUAGE OverloadedStrings #-}++module Text.MMark.Extension.EmojiSpec (spec) where++import Data.Map.Strict qualified as M+import Test.Hspec+import Text.MMark.Extension.Emoji+import Text.MMark.Extension.TestUtils++spec :: Spec+spec = do+  emojiSpec+  emojiWithSpec++emojiSpec :: Spec+emojiSpec = describe "emoji" $ do+  it "replaces a shortcode it knows" $+    withTrans emoji "Hi :smile: there" "<p>Hi \128578 there</p>\n"+  it "replaces several in one go" $+    withTrans emoji ":fire: :rocket:" "<p>\128293 \128640</p>\n"+  it "replaces one that is more than one code point" $+    withTrans emoji ":warning:" "<p>\9888\65039</p>\n"+  it "replaces a shortcode that is an alias of another" $+    withTrans emoji ":joy: :laughing:" "<p>\128514 \128514</p>\n"+  it "replaces one whose name is not letters" $+    withTrans emoji ":+1: :100:" "<p>\128077 \128175</p>\n"+  it "reports a shortcode it does not know" $+    transErrors emoji "Hi :nosuch: there"+      `shouldReturn` ["1:1: there is no emoji called \"nosuch\""]+  it "leaves a lone colon alone" $+    withTrans emoji "at 12:30 sharp" "<p>at 12:30 sharp</p>\n"+  it "leaves text with no colons alone" $+    withTrans emoji "nothing here" "<p>nothing here</p>\n"+  it "leaves a shortcode in a code span alone" $+    withTrans emoji "`:smile:`" "<p><code>:smile:</code></p>\n"+  it "replaces a shortcode nested in other markup" $+    withTrans emoji "**:fire:**" "<p><strong>\128293</strong></p>\n"+  it "reports every unknown shortcode, not just the first" $+    transErrors emoji ":nosuch: and :neither:"+      `shouldReturn` [ "1:1: there is no emoji called \"nosuch\"",+                       "1:1: there is no emoji called \"neither\""+                     ]++emojiWithSpec :: Spec+emojiWithSpec = describe "emojiWith" $ do+  it "uses the table it is given" $+    withTrans (emojiWith table) "look :cat:" "<p>look \128049</p>\n"+  it "reports a shortcode the table does not have" $+    transErrors (emojiWith table) ":smile:"+      `shouldReturn` ["1:1: there is no emoji called \"smile\""]+  it "reports nothing for a table that has everything" $+    transErrors (emojiWith table) ":cat: :dog:" `shouldReturn` []+  it "reports every unknown shortcode of a paragraph" $+    transErrors (emojiWith table) ":nope: and :also:"+      `shouldReturn` [ "1:1: there is no emoji called \"nope\"",+                       "1:1: there is no emoji called \"also\""+                     ]+  it "replaces nothing when the table is empty" $+    withTrans (emojiWith mempty) "no colons here" "<p>no colons here</p>\n"+  where+    table = M.fromList [("cat", "\128049"), ("dog", "\128054")]
− tests/Text/MMark/Extension/FontAwesomeSpec.hs
@@ -1,24 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Text.MMark.Extension.FontAwesomeSpec (spec) where--import Test.Hspec-import Text.MMark.Extension.FontAwesome-import Text.MMark.Extension.TestUtils--spec :: Spec-spec =-  describe "fontAwesome" $ do-    let to = withExt fontAwesome-    context "when URI has the fa scheme" $-      it "produces the correct HTML" $ do-        "<fa:>" `to` "<p><a href=\"fa:\">fa:</a></p>\n"-        "<fa:user>" `to` "<p><span class=\"fa fa-user\"></span></p>\n"-        "<fa:user/lg>" `to` "<p><span class=\"fa fa-user fa-lg\"></span></p>\n"-        "<fa:quote-left/3x/pull-left/border>" `to` "<p><span class=\"fa fa-quote-left fa-3x fa-pull-left fa-border\"></span></p>\n"-    context "when URI has some other scheme" $-      it "produces the correct HTML" $-        "<https://example.org>" `to` "<p><a href=\"https://example.org\">https://example.org</a></p>\n"-    context "other elements" $-      it "not affected" $-        "Something." `to` "<p>Something.</p>\n"
tests/Text/MMark/Extension/FootnotesSpec.hs view
@@ -2,7 +2,9 @@  module Text.MMark.Extension.FootnotesSpec (spec) where +import Data.Text (Text) import Test.Hspec+import Text.MMark qualified as MMark import Text.MMark.Extension.Footnotes import Text.MMark.Extension.TestUtils @@ -10,23 +12,63 @@ spec =   describe "footnotes" $ do     let to = withExt footnotes-    context "when link has no scheme" $-      it "has no effect" $-        "Link [link](1)."-          `to` "<p>Link <a href=\"1\">link</a>.</p>\n"-    context "when link has not \"footnote\" scheme" $-      it "has no effect" $-        "Link [link](https:1)"-          `to` "<p>Link <a href=\"https:1\">link</a></p>\n"-    context "when link has \"footnote\" scheme" $-      it "transforms the link correctly" $-        "Link [link](footnote:1)"-          `to` "<p>Link <a href=\"#fn1\" id=\"fnref1\"><sup>1</sup></a></p>\n"-    context "when block quotes are not formatted correctly" $-      it "has no effect" $-        "> blah"-          `to` "<blockquote>\n<p>blah</p>\n</blockquote>\n"-    context "when block quotes are formatted correctly" $-      it "transforms them into footnotes" $-        "> footnotes\n\n  1. Something.\n"-          `to` "<ol>\n<li id=\"fn1\">\nSomething.\n<a href=\"#fnref1\">↩</a></li>\n</ol>\n"+    context "when link has no scheme"+      $ it "has no effect"+      $ "Link [link](1)."+        `to` "<p>Link <a href=\"1\">link</a>.</p>\n"+    context "when link has not \"footnote\" scheme"+      $ it "has no effect"+      $ "Link [link](https:1)"+        `to` "<p>Link <a href=\"https:1\">link</a></p>\n"+    context "when link has \"footnote\" scheme"+      $ it "transforms the link correctly"+      $ "Link [link](footnote:1)"+        `to` "<p>Link <a href=\"#fn1\" id=\"fnref1\"><sup>1</sup></a></p>\n"+    context "when block quotes are not formatted correctly"+      $ it "has no effect"+      $ "> blah"+        `to` "<blockquote>\n<p>blah</p>\n</blockquote>\n"+    context "when block quotes are formatted correctly"+      $ it "transforms them into footnotes"+      $ "> footnotes\n>\n> 1. Something.\n"+        `to` "<ol>\n<li id=\"fn1\">\nSomething.\n<a href=\"#fnref1\">↩</a></li>\n</ol>\n"+    context "validation" $ do+      it "accepts a document whose footnotes all line up" $+        check "Text [1](footnote:1).\n\n> footnotes\n>\n> 1. The note.\n"+          `shouldReturn` []+      it "reports a reference to a footnote that does not exist" $+        check "Text [2](footnote:2).\n\n> footnotes\n>\n> 1. The note.\n"+          `shouldReturn` ["1:6: there is no footnote 2", "5:6: nothing refers to footnote 1"]+      it "reports a footnote nothing refers to" $+        check "Text.\n\n> footnotes\n>\n> 1. Orphan.\n"+          `shouldReturn` ["5:6: nothing refers to footnote 1"]+      it "reports a footnote that is referred to more than once" $+        check "A [1](footnote:1) and B [1](footnote:1).\n\n> footnotes\n>\n> 1. N.\n"+          `shouldReturn` [ "1:25: footnote 1 is referred to more than once, which would give the references the same id"+                         ]+      it "reports a reference whose path is not a number" $+        check "Text [x](footnote:abc).\n\n> footnotes\n>\n> 1. N.\n"+          `shouldReturn` [ "1:6: a footnote reference must have a single number as its path",+                           "5:6: nothing refers to footnote 1"+                         ]+      it "reaches references nested inside other inlines" $+        check "T [1](footnote:1) *and [2](footnote:2)*.\n\n> footnotes\n>\n> 1. A.\n> 2. B.\n"+          `shouldReturn` []+      it "reports every problem exactly once" $+        check "[9](footnote:9)\n\nSome text.\n\nMore text.\n"+          `shouldReturn` ["1:1: there is no footnote 9"]+      -- The footnotes of the second section are the same numbers as those+      -- of the first, so they are not counted twice; the document is+      -- already reported as having more than one section.+      it "reports a second footnote section" $+        check "> footnotes\n>\n> 1. A.\n\n> footnotes\n>\n> 1. B.\n"+          `shouldReturn` [ "3:6: nothing refers to footnote 1",+                           "5:1: there is more than one footnote section"+                         ]++-- | Validate the footnotes of a document, returning one @line:col: message@+-- string per reported problem.+check :: Text -> IO [Text]+check input = do+  Right doc <- pure (MMark.parse "" input)+  checkErrors (validateFootnotes (MMark.runScanner footnoteScanner doc)) input
tests/Text/MMark/Extension/GhcSyntaxHighlighterSpec.hs view
@@ -2,6 +2,7 @@  module Text.MMark.Extension.GhcSyntaxHighlighterSpec (spec) where +import Data.Text qualified as T import Test.Hspec import Text.MMark.Extension.GhcSyntaxHighlighter import Text.MMark.Extension.TestUtils@@ -10,7 +11,42 @@ spec =   describe "ghcSyntaxHighlighter" $ do     let to = withExt ghcSyntaxHighlighter-    context "with info string is \"haskell\"" $-      it "renders it correctly" $-        "```haskell\nmain :: IO ()\nmain = return ()\n```\n"-          `to` "<div class=\"source-code\"><pre><code class=\"language-haskell\"><span class=\"va\">main</span><span> </span><span class=\"sy\">::</span><span> </span><span class=\"cr\">IO</span><span> </span><span class=\"sy\">(</span><span class=\"sy\">)</span><span>\n</span><span class=\"va\">main</span><span> </span><span class=\"sy\">=</span><span> </span><span class=\"va\">return</span><span> </span><span class=\"sy\">(</span><span class=\"sy\">)</span><span>\n</span></code></pre></div>\n"+    context "with info string is \"haskell\""+      $ it "renders it correctly"+      $ "```haskell\nmain :: IO ()\nmain = return ()\n```\n"+        `to` "<div class=\"source-code\"><pre><code class=\"language-haskell\"><span class=\"va\">main</span><span> </span><span class=\"sy\">::</span><span> </span><span class=\"cr\">IO</span><span> </span><span class=\"sy\">(</span><span class=\"sy\">)</span><span>\n</span><span class=\"va\">main</span><span> </span><span class=\"sy\">=</span><span> </span><span class=\"va\">return</span><span> </span><span class=\"sy\">(</span><span class=\"sy\">)</span><span>\n</span></code></pre></div>\n"+    context "when the info string ends with a line specification" $ do+      it "still recognizes the language, and points at the line" $+        "```haskell {2}\nmain :: IO ()\nmain = return ()\n```\n"+          `to` T.concat+            [ "<div class=\"source-code\"><pre><code class=\"language-haskell\">",+              "<span class=\"va\">main</span><span> </span><span class=\"sy\">::</span><span> </span><span class=\"cr\">IO</span><span> </span><span class=\"sy\">(</span><span class=\"sy\">)</span>\n",+              "<span class=\"highlighted-line\">",+              "<span class=\"va\">main</span><span> </span><span class=\"sy\">=</span><span> </span><span class=\"va\">return</span><span> </span><span class=\"sy\">(</span><span class=\"sy\">)</span>\n",+              "</span>",+              "</code></pre></div>\n"+            ]+      it "does not take a specification that names no line for one" $+        -- there is no such thing as pointing at nothing, so this is a+        -- malformed info string and the whole of it names the language+        "```haskell {}\nmain :: IO ()\nmain = return ()\n```\n"+          `to` "<pre><code class=\"language-haskell\">main :: IO ()\nmain = return ()\n</code></pre>\n"+      it "counts the lines the way Data.Text.lines does" $+        -- a trailing newline ends the last line, it does not start another,+        -- so there is no line 3 here to point at+        "```haskell {3}\nmain :: IO ()\nmain = return ()\n```\n"+          `to` "<div class=\"source-code\"><pre><code class=\"language-haskell\"><span class=\"va\">main</span><span> </span><span class=\"sy\">::</span><span> </span><span class=\"cr\">IO</span><span> </span><span class=\"sy\">(</span><span class=\"sy\">)</span>\n<span class=\"va\">main</span><span> </span><span class=\"sy\">=</span><span> </span><span class=\"va\">return</span><span> </span><span class=\"sy\">(</span><span class=\"sy\">)</span>\n</code></pre></div>\n"+      it "cuts a token that runs across lines at the newline" $+        -- the comment is one token spanning two lines; pointing at the+        -- second of them must not swallow the first+        "```haskell {2}\nx = 1\n{- a\nb -}\n```\n"+          `to` T.concat+            [ "<div class=\"source-code\"><pre><code class=\"language-haskell\">",+              "<span class=\"va\">x</span><span> </span><span class=\"sy\">=</span><span> </span><span class=\"it\">1</span>\n",+              "<span class=\"highlighted-line\"><span class=\"co\">{- a</span>\n</span>",+              "<span class=\"co\">b -}</span>\n",+              "</code></pre></div>\n"+            ]+      it "leaves a block of another language alone" $+        "```rust {1}\nfn main() {}\n```\n"+          `to` "<pre><code class=\"language-rust\">fn main() {}\n</code></pre>\n"
+ tests/Text/MMark/Extension/HeadingSpec.hs view
@@ -0,0 +1,73 @@+{-# LANGUAGE OverloadedStrings #-}++module Text.MMark.Extension.HeadingSpec (spec) where++import Data.Text (Text)+import Data.Text qualified as T+import Test.Hspec+import Text.MMark qualified as MMark+import Text.MMark.Extension.Heading+import Text.MMark.Extension.TestUtils++spec :: Spec+spec = do+  describe "checkHeadings" $ do+    it "reports a heading that skips a level" $+      headingErrors "# A\n\n### B"+        `shouldReturn` [ "3:1: this heading is of level 3, but the one before it is of level 1, so the outline of the document skips a level"+                       ]+    it "reports a second level 1 heading" $+      headingErrors "# A\n\n# B"+        `shouldReturn` ["3:1: there is more than one level 1 heading in this document"]+    it "reports two headings that get the same id" $+      headingErrors "# A\n\n## A"+        `shouldReturn` ["3:1: another heading is already given the id \"a\""]+    it "accepts a well formed outline" $+      headingErrors "# A\n\n## B\n\n### C\n\n## D" `shouldReturn` []+    it "reports each problem exactly once" $+      headingErrors "# A\n\nSome text.\n\nMore text.\n\n### B"+        `shouldReturn` [ "7:1: this heading is of level 3, but the one before it is of level 1, so the outline of the document skips a level"+                       ]++  describe "headingProblems" $ do+    it "finds nothing in a document with no headings" $+      problems "just some text" `shouldBe` []+    it "finds nothing in a well formed outline" $+      problems "# A\n\n## B\n\n### C\n\n## D" `shouldBe` []+    it "accepts an outline that comes back up several levels at once" $+      problems "# A\n\n## B\n\n### C\n\n## D\n\n# E" `shouldBe` ["title"]+    it "accepts a document that starts below level 1" $+      problems "## A\n\n### B" `shouldBe` []+    it "names the level a heading skips to and from" $+      problems "## A\n\n##### B" `shouldBe` ["skip"]+    it "finds a problem of each kind at once" $+      problems "# A\n\n### B\n\n# A" `shouldBe` ["skip", "title", "collision"]+    it "reports the problems in the order they appear" $+      problems "# A\n\n# B\n\n### C"+        `shouldBe` ["title", "skip"]+    it "finds a collision between headings of different levels" $+      problems "# Same\n\n## Same" `shouldBe` ["collision"]+    it "reports every heading after the first that shares an id" $+      problems "## A\n\n## A\n\n## A" `shouldBe` ["collision", "collision"]+    it "sees headings inside a block quote as part of the outline" $+      problems "# A\n\n> ### B" `shouldBe` []++-- | Scan a document for its headings and check them.+headingErrors :: Text -> IO [Text]+headingErrors input = do+  Right doc <- pure (MMark.parse "" input)+  checkErrors (checkHeadings (MMark.runScanner headingScanner doc)) input++-- | The problems of a document, each named by its kind so that a test does+-- not have to repeat the whole message.+problems :: Text -> [Text]+problems input = kindOf . snd <$> headingProblems (scan input)+  where+    scan t = case MMark.parse "" t of+      Left _ -> error "the test input does not parse"+      Right doc -> MMark.runScanner headingScanner doc+    kindOf msg+      | "skips a level" `T.isSuffixOf` msg = "skip"+      | "more than one level 1" `T.isInfixOf` msg = "title"+      | "already given the id" `T.isInfixOf` msg = "collision"+      | otherwise = msg
+ tests/Text/MMark/Extension/IconsSpec.hs view
@@ -0,0 +1,65 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++module Text.MMark.Extension.IconsSpec (spec) where++import Data.Map.Strict (Map)+import Data.Map.Strict qualified as M+import Data.Text (Text)+import Lucid+import Test.Hspec+import Text.MMark.Extension.Icons+import Text.MMark.Extension.TestUtils+import Text.URI.QQ (scheme)++spec :: Spec+spec = do+  describe "icons" $ do+    let to = withExt (icons table)+    it "puts the SVG of an autolink in place of it" $+      "<icon:github>"+        `to` "<p><span class=\"icon icon-github\" aria-hidden=\"true\"><svg id=\"gh\"></svg></span></p>\n"+    it "labels an icon that has link text" $+      "[GitHub](icon:github)"+        `to` "<p><span class=\"icon icon-github\" role=\"img\" aria-label=\"GitHub\"><svg id=\"gh\"></svg></span></p>\n"+    it "turns the rest of the path into classes" $+      "<icon:github/lg/pull-left>"+        `to` "<p><span class=\"icon icon-github icon-lg icon-pull-left\" aria-hidden=\"true\"><svg id=\"gh\"></svg></span></p>\n"+    it "leaves an icon it does not have as a link" $+      "<icon:nosuch>" `to` "<p><a href=\"icon:nosuch\">icon:nosuch</a></p>\n"+    it "leaves a link with no icon name alone" $+      "<icon:>" `to` "<p><a href=\"icon:\">icon:</a></p>\n"+    it "leaves a link of another scheme alone" $+      "<https://example.org>"+        `to` "<p><a href=\"https://example.org\">https://example.org</a></p>\n"+    it "leaves other inlines alone" $+      "Something." `to` "<p>Something.</p>\n"+  describe "iconsWith" $ do+    let to = withExt (iconsWith [scheme|fa|] "fa" table)+    it "uses the scheme and the prefix it is given" $+      "<fa:github>"+        `to` "<p><span class=\"fa fa-github\" aria-hidden=\"true\"><svg id=\"gh\"></svg></span></p>\n"+    it "leaves the scheme it replaces alone" $+      "<icon:github>" `to` "<p><a href=\"icon:github\">icon:github</a></p>\n"+  describe "checkIcons" $ do+    it "reports an icon it does not have" $+      transErrors (checkIcons table) "See <icon:nosuch> there"+        `shouldReturn` ["1:5: there is no icon called \"nosuch\""]+    it "reports a link that names no icon" $+      transErrors (checkIcons table) "See <icon:> there"+        `shouldReturn` ["1:5: this link names no icon"]+    it "says nothing about an icon it has" $+      transErrors (checkIcons table) "See <icon:github/lg> there"+        `shouldReturn` []+    it "says nothing about a link of another scheme" $+      transErrors (checkIcons table) "See <https://example.org> there"+        `shouldReturn` []+  describe "checkIconsWith"+    $ it "uses the scheme it is given"+    $ transErrors (checkIconsWith [scheme|fa|] table) "See <fa:nosuch> there"+      `shouldReturn` ["1:5: there is no icon called \"nosuch\""]++-- | An icon table with something recognizable in it. Raw SVG is how an icon+-- usually arrives, so that is what the table holds here.+table :: Map Text (Html ())+table = toHtmlRaw <$> M.fromList [("github", "<svg id=\"gh\"></svg>" :: Text)]
+ tests/Text/MMark/Extension/ImageSpec.hs view
@@ -0,0 +1,242 @@+{-# LANGUAGE OverloadedStrings #-}++module Text.MMark.Extension.ImageSpec (spec) where++import Data.Bits (shiftR, (.&.))+import Data.ByteString (ByteString)+import Data.ByteString qualified as B+import Data.ByteString.Char8 qualified as B8+import Data.Map.Strict qualified as M+import Data.Text (Text)+import Data.Word (Word8)+import System.FilePath ((</>))+import Test.Hspec+import Text.MMark qualified as MMark+import Text.MMark.Extension.Image+import Text.MMark.Extension.TestUtils+import Text.MMark.Trans (Span)+import Text.URI qualified as URI++spec :: Spec+spec = do+  describe "checkAltText" $ do+    it "reports an image with no description" $+      transErrors checkAltText "![](/a.png)"+        `shouldReturn` ["1:1: this image has no description for the alt attribute"]+    it "accepts an image with a description" $+      transErrors checkAltText "![a cat](/a.png)" `shouldReturn` []+    it "reports an image nested in a link" $+      transErrors checkAltText "[![](/a.png)](/x)"+        `shouldReturn` ["1:2: this image has no description for the alt attribute"]+    it "finds an image inside a block quote" $+      transErrors checkAltText "> ![](/a.png)"+        `shouldReturn` ["1:3: this image has no description for the alt attribute"]+    it "reports every undescribed image, once each" $+      transErrors checkAltText "![](/a.png) ![b](/b.png) ![](/c.png)"+        `shouldReturn` [ "1:1: this image has no description for the alt attribute",+                         "1:26: this image has no description for the alt attribute"+                       ]++  describe "lazyImages" $ do+    it "adds the loading and decoding attributes" $+      withExt+        lazyImages+        "![a cat](/a.png)"+        "<p><img loading=\"lazy\" decoding=\"async\" alt=\"a cat\" src=\"/a.png\"></p>\n"+    it "leaves other inlines alone" $+      withExt lazyImages "[a link](/x)" "<p><a href=\"/x\">a link</a></p>\n"++  describe "imageScanner" $ do+    it "collects the URI of an image" $+      scanned "![a cat](/a.png)" `shouldBe` ["/a.png"]+    it "collects every image of a document" $+      scanned "![a](/a.png)\n\n![b](/b.png)" `shouldBe` ["/a.png", "/b.png"]+    it "collects an image nested in a link and in a quote" $+      scanned "[![a](/a.png)](/x)\n\n> ![b](/b.png)"+        `shouldBe` ["/a.png", "/b.png"]+    it "keeps two images with the same URI apart" $+      length (M.toList (scan "![a](/a.png) ![a](/a.png)")) `shouldBe` 2+    it "collects nothing from a document with no images" $+      scanned "just some text" `shouldBe` []++  describe "imageDimensions" $ do+    it "gives an image the size it was measured to have" $+      withSizes+        (Just (640, 480))+        "![a cat](/a.png)"+        "<p><img width=\"640\" height=\"480\" alt=\"a cat\" src=\"/a.png\"></p>\n"+    it "leaves an image that could not be measured alone" $+      withSizes+        Nothing+        "![a cat](/a.png)"+        "<p><img alt=\"a cat\" src=\"/a.png\"></p>\n"+    it "leaves an image with no measurement at all alone" $+      withExt+        (imageDimensions M.empty)+        "![a cat](/a.png)"+        "<p><img alt=\"a cat\" src=\"/a.png\"></p>\n"+    it "composes with lazyImages" $+      withSizesUsing+        (lazyImages <>)+        (Just (7, 3))+        "![a cat](/a.png)"+        "<p><img loading=\"lazy\" decoding=\"async\" width=\"7\" height=\"3\" alt=\"a cat\" src=\"/a.png\"></p>\n"++  describe "imageSizeOf" $ do+    it "measures a PNG" $+      measuring (pngBytes 7 3) `shouldReturn` Just (7, 3)+    it "measures a PNG larger than a byte in each direction" $+      measuring (pngBytes 1920 1080) `shouldReturn` Just (1920, 1080)+    it "measures a GIF" $+      measuring (gifBytes 11 5) `shouldReturn` Just (11, 5)+    it "measures a GIF larger than a byte in each direction" $+      measuring (gifBytes 800 600) `shouldReturn` Just (800, 600)+    it "measures a JPEG" $+      measuring (jpegBytes [] 13 9) `shouldReturn` Just (13, 9)+    it "measures a JPEG behind a segment it does not care about" $+      measuring (jpegBytes [app0, comment 40] 320 240)+        `shouldReturn` Just (320, 240)+    it "measures a JPEG behind a marker that carries no payload" $+      -- 0xD8 is SOI, whose two following bytes are not a length; a walk+      -- that reads them as one lands in the middle of nothing.+      measuring (jpegBytes [app0, standalone 0xD8, comment 8] 64 48)+        `shouldReturn` Just (64, 48)+    it "measures a JPEG whose frame is not the baseline one" $+      -- SOF2, the progressive frame header+      measuring (jpegBytesWith 0xC2 [app0] 21 12) `shouldReturn` Just (21, 12)+    it "does not mistake a huffman table for a frame" $+      -- 0xC4 is in the SOF range by number but is not a frame+      measuring (jpegBytes [tableNotAFrame] 30 20) `shouldReturn` Just (30, 20)+    it "gives up on a JPEG that ends before its frame" $+      measuring (B.pack [0xFF, 0xD8] <> app0) `shouldReturn` Nothing+    it "gives up on a JPEG whose segment lengths are nonsense" $+      -- a segment that claims to be no bytes long, then one that claims to+      -- run past the end of the file+      measuring+        ( B.pack [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x00]+            <> B.replicate 8 0x20+            <> B.pack [0xFF, 0xC0]+            <> be16 11+            <> B.pack [8]+            <> be16 99+            <> be16 99+            <> B.pack [1, 1, 0x11, 0]+        )+        `shouldReturn` Nothing+    it "gives up on a file that is not an image" $+      measuring (B8.pack "just some text, not an image at all")+        `shouldReturn` Nothing+    it "gives up on an empty file" $+      measuring B.empty `shouldReturn` Nothing+    it "gives up on a truncated PNG" $+      measuring (B.take 20 (pngBytes 7 3)) `shouldReturn` Nothing+    it "gives up on a truncated GIF" $+      measuring (B.take 8 (gifBytes 11 5)) `shouldReturn` Nothing+    it "gives up on a file that is not there instead of throwing" $+      withTempDir (\dir -> imageSizeOf (dir </> "nope.png"))+        `shouldReturn` Nothing+    it "gives up on a directory instead of throwing" $+      withTempDir imageSizeOf `shouldReturn` Nothing++----------------------------------------------------------------------------+-- Helpers++-- | Scan a document and return the URI of every image it has, in order.+scanned :: Text -> [Text]+scanned = fmap URI.render . M.elems . scan++scan :: Text -> M.Map Span URI.URI+scan input = case MMark.parse "" input of+  Left _ -> error "the test input does not parse"+  Right doc -> MMark.runScanner imageScanner doc++-- | Render a document with every image measured as the given size.+withSizes :: Maybe (Int, Int) -> Text -> Text -> Expectation+withSizes = withSizesUsing id++withSizesUsing ::+  -- | What else to render with+  (MMark.RenderExtension -> MMark.RenderExtension) ->+  -- | The size every image is measured to have+  Maybe (Int, Int) ->+  -- | Input for the parser+  Text ->+  -- | Expected output of the render+  Text ->+  Expectation+withSizesUsing f size input expected =+  withExt (f (imageDimensions (size <$ scan input))) input expected++-- | The bytes of a PNG of the given size: the signature and the @IHDR@+-- chunk, which is all that states the size.+pngBytes :: Int -> Int -> ByteString+pngBytes w h =+  B.pack [137, 80, 78, 71, 13, 10, 26, 10]+    <> be32 13+    <> B8.pack "IHDR"+    <> be32 w+    <> be32 h+    <> B.pack [8, 2, 0, 0, 0]++-- | The bytes of a GIF of the given size: the signature and the logical+-- screen descriptor.+gifBytes :: Int -> Int -> ByteString+gifBytes w h = B8.pack "GIF89a" <> le16 w <> le16 h <> B.pack [0, 0, 0]++-- | The bytes of a JPEG of the given size: @SOI@, the given segments, then+-- a baseline frame header.+jpegBytes :: [ByteString] -> Int -> Int -> ByteString+jpegBytes = jpegBytesWith 0xC0++-- | Like 'jpegBytes', but you choose which frame header states the size.+jpegBytesWith :: Word8 -> [ByteString] -> Int -> Int -> ByteString+jpegBytesWith marker leading w h =+  B.pack [0xFF, 0xD8] <> B.concat leading <> sof <> B.pack [0xFF, 0xD9]+  where+    sof =+      B.pack [0xFF, marker]+        <> be16 11+        <> B.pack [8]+        <> be16 h+        <> be16 w+        <> B.pack [1, 1, 0x11, 0]++-- | A @JFIF@ header, the segment that usually comes first.+app0 :: ByteString+app0 =+  B.pack [0xFF, 0xE0]+    <> be16 16+    <> B8.pack "JFIF\NUL"+    <> B.pack [1, 1, 0, 0, 1, 0, 1, 0, 0]++-- | A comment segment carrying the given number of bytes of padding.+comment :: Int -> ByteString+comment n = B.pack [0xFF, 0xFE] <> be16 (n + 2) <> B.replicate n 0x20++-- | A marker that carries no payload at all.+standalone :: Word8 -> ByteString+standalone m = B.pack [0xFF, m]++-- | A huffman table, which sits in the range the frame headers occupy but+-- is not one of them.+tableNotAFrame :: ByteString+tableNotAFrame = B.pack [0xFF, 0xC4] <> be16 6 <> B.replicate 4 0++-- | Write the given bytes to a file and measure it.+measuring :: ByteString -> IO (Maybe (Int, Int))+measuring bs = withTempDir $ \dir -> do+  let path = dir </> "image"+  B.writeFile path bs+  imageSizeOf path++be32 :: Int -> ByteString+be32 n = B.pack (fmap (byte n) [24, 16, 8, 0])++be16 :: Int -> ByteString+be16 n = B.pack (fmap (byte n) [8, 0])++le16 :: Int -> ByteString+le16 n = B.pack (fmap (byte n) [0, 8])++byte :: Int -> Int -> Word8+byte n s = fromIntegral ((n `shiftR` s) .&. 0xFF)
+ tests/Text/MMark/Extension/LineHighlightSpec.hs view
@@ -0,0 +1,78 @@+{-# LANGUAGE OverloadedStrings #-}++module Text.MMark.Extension.LineHighlightSpec (spec) where++import Data.Text qualified as T+import Test.Hspec+import Text.MMark.Extension.LineHighlight+import Text.MMark.Extension.TestUtils++spec :: Spec+spec = do+  describe "parseLineSpec" $ do+    it "reads a single line" $+      parseLineSpec "haskell {2}" `shouldBe` Just (Just "haskell", [2])+    it "reads a range" $+      parseLineSpec "haskell {4-6}" `shouldBe` Just (Just "haskell", [4, 5, 6])+    it "reads a mixture" $+      parseLineSpec "haskell {2,4-6}" `shouldBe` Just (Just "haskell", [2, 4, 5, 6])+    it "works without a language" $+      parseLineSpec "{1}" `shouldBe` Just (Nothing, [1])+    it "gives nothing when there is no specification" $+      parseLineSpec "haskell" `shouldBe` Nothing+    it "gives nothing when the specification makes no sense" $ do+      parseLineSpec "haskell {x}" `shouldBe` Nothing+      parseLineSpec "haskell {6-4}" `shouldBe` Nothing+  describe "lineHighlight" $ do+    it "points at the line it is told to" $+      withExt+        lineHighlight+        "```haskell {2}\none\ntwo\n```"+        "<pre><code class=\"language-haskell\">one\n<span class=\"highlighted-line\">two\n</span></code></pre>\n"+    it "leaves a code block with no specification alone" $+      withExt+        lineHighlight+        "```haskell\none\n```"+        "<pre><code class=\"language-haskell\">one\n</code></pre>\n"+    it "renders a block of real code, pointing where it is told" $+      withExt+        lineHighlight+        ( T.unlines+            [ "```haskell {2,4-6}",+              "module Main (main) where",+              "",+              "import Data.List (sort & \"x\")",+              "main :: IO ()",+              "main = print (sort [3,1,2] <> [])",+              "-- done",+              "```"+            ]+        )+        ( T.concat+            [ "<pre><code class=\"language-haskell\">",+              "module Main (main) where\n",+              "<span class=\"highlighted-line\">\n</span>",+              "import Data.List (sort &amp; &quot;x&quot;)\n",+              "<span class=\"highlighted-line\">main :: IO ()\n</span>",+              "<span class=\"highlighted-line\">main = print (sort [3,1,2] &lt;&gt; [])\n</span>",+              "<span class=\"highlighted-line\">-- done\n</span>",+              "</code></pre>\n"+            ]+        )+    it "gives a block with no language no class to be styled by" $+      withExt+        lineHighlight+        "``` {1,3}\na\nb\nc\n```"+        ( T.concat+            [ "<pre><code>",+              "<span class=\"highlighted-line\">a\n</span>",+              "b\n",+              "<span class=\"highlighted-line\">c\n</span>",+              "</code></pre>\n"+            ]+        )+    it "ignores a line the block does not have" $+      withExt+        lineHighlight+        "```haskell {2,9}\na\nb\nc\n```"+        "<pre><code class=\"language-haskell\">a\n<span class=\"highlighted-line\">b\n</span>c\n</code></pre>\n"
+ tests/Text/MMark/Extension/LinkSpec.hs view
@@ -0,0 +1,132 @@+{-# LANGUAGE OverloadedStrings #-}++module Text.MMark.Extension.LinkSpec (spec) where++import Data.ByteString qualified as B+import Data.IORef+import Data.Text (Text)+import Data.Text qualified as T+import System.Directory (createDirectory)+import System.FilePath ((</>))+import Test.Hspec+import Text.MMark qualified as MMark+import Text.MMark.Extension.Link+import Text.MMark.Extension.TestUtils+import Text.URI (URI)+import Text.URI qualified as URI++spec :: Spec+spec = do+  describe "linkTarget" $ do+    let to = withExt linkTarget+    context "when no link title provided"+      $ it "has no effect"+      $ "[link](/url)" `to` "<p><a href=\"/url\">link</a></p>\n"+    context "when link title does not start with a target"+      $ it "has no effect"+      $ "[link](/url 'something _blank')"+        `to` "<p><a href=\"/url\" title=\"something _blank\">link</a></p>\n"+    context "when link title starts with a target" $ do+      context "when there is nothing but the target in title"+        $ it "works as intended, no title attribute produced"+        $ "[link](/url '_blank')"+          `to` "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"/url\">link</a></p>\n"+      context "when there is also a title"+        $ it "works as intended, target is stripped from the title"+        $ "[link](/url '_blank something')"+          `to` "<p><a target=\"_blank\" rel=\"noopener noreferrer\" href=\"/url\" title=\"something\">link</a></p>\n"+      context "when the target is not a new browsing context"+        $ it "does not add a rel attribute"+        $ "[link](/url '_self something')"+          `to` "<p><a target=\"_self\" href=\"/url\" title=\"something\">link</a></p>\n"+  describe "checkFragments" $ do+    it "accepts a link to a heading that exists" $+      fragmentErrors "# Real\n\n[go](#real)" `shouldReturn` []+    it "reports a link to a heading that does not" $+      fragmentErrors "# Real\n\n[go](#nope)"+        `shouldReturn` ["3:1: no heading of this document has the id \"nope\""]+    it "leaves a link with a scheme alone" $+      fragmentErrors "[go](https://example.org#nope)" `shouldReturn` []+    it "reports every bad fragment, once each" $+      fragmentErrors "# R\n\n[a](#x) and [b](#y)"+        `shouldReturn` [ "3:1: no heading of this document has the id \"x\"",+                         "3:13: no heading of this document has the id \"y\""+                       ]++  describe "checkLocalFiles" $ do+    it "accepts a link to a file that is there" $+      localErrors "[go](there.txt)" `shouldReturn` []+    it "reports a link to a file that is not" $+      localErrors "[go](nope.txt)"+        `shouldReturn` ["1:1: there is nothing at ./nope.txt"]+    it "accepts a link to a directory" $+      localErrors "[go](sub)" `shouldReturn` []+    it "accepts a link to a file in a subdirectory" $+      localErrors "[go](sub/deep.txt)" `shouldReturn` []+    it "ignores the fragment of a link to a file that is there" $+      localErrors "[go](there.txt#part)" `shouldReturn` []+    it "leaves a link with a scheme to checkExternal" $+      localErrors "[go](https://example.org/nope.txt)" `shouldReturn` []+    it "leaves a link that is only a fragment alone" $+      localErrors "[go](#part)" `shouldReturn` []+    it "checks images too" $+      localErrors "![x](nope.png)"+        `shouldReturn` ["1:1: there is nothing at ./nope.png"]+    it "reports every missing file, once each" $+      localErrors "[a](nope.txt) and [b](gone.txt)"+        `shouldReturn` [ "1:1: there is nothing at ./nope.txt",+                         "1:19: there is nothing at ./gone.txt"+                       ]++  describe "checkExternal" $ do+    it "accepts a link the action says is reachable" $+      externalErrors (const (pure True)) "[go](https://example.org)"+        `shouldReturn` []+    it "reports a link the action says is not" $+      externalErrors (const (pure False)) "[go](https://example.org)"+        `shouldReturn` ["1:1: cannot reach https://example.org"]+    it "hands the action the URI of the link" $ do+      seen <- asked (const True) "[go](https://example.org/a)"+      seen `shouldBe` ["https://example.org/a"]+    it "does not ask about a link with no scheme" $+      asked (const True) "[go](nope.txt)" `shouldReturn` []+    it "does not ask about a link that is only a fragment" $+      asked (const True) "[go](#part)" `shouldReturn` []+    it "asks about every external link, once each" $+      asked (const True) "[a](https://a.example) [b](https://b.example)"+        `shouldReturn` ["https://a.example", "https://b.example"]+    it "checks images too" $+      externalErrors (const (pure False)) "![x](https://example.org/a.png)"+        `shouldReturn` ["1:1: cannot reach https://example.org/a.png"]++-- | Scan a document for its header ids and check its fragments.+fragmentErrors :: Text -> IO [Text]+fragmentErrors input = do+  Right doc <- pure (MMark.parse "" input)+  transErrors (checkFragments (MMark.runScanner headerIdScanner doc)) input++-- | Check the local links of a document against a directory holding+-- @there.txt@ and @sub\/deep.txt@.+localErrors :: Text -> IO [Text]+localErrors input = withTempDir $ \dir -> do+  B.writeFile (dir </> "there.txt") ""+  createDirectory (dir </> "sub")+  B.writeFile (dir </> "sub" </> "deep.txt") ""+  errs <- transErrorsM (checkLocalFiles dir) input+  -- the messages name the base directory, which is a different one every+  -- run, so put something back that a test can be written against+  return (T.replace (T.pack dir) "." <$> errs)++-- | Check the external links of a document with the given action.+externalErrors :: (URI -> IO Bool) -> Text -> IO [Text]+externalErrors reachable = transErrorsM (checkExternal reachable)++-- | The URIs 'checkExternal' asked the action about, in order.+asked :: (URI -> Bool) -> Text -> IO [Text]+asked answer input = do+  ref <- newIORef []+  _ <-+    externalErrors+      (\uri -> modifyIORef' ref (URI.render uri :) >> pure (answer uri))+      input+  reverse <$> readIORef ref
− tests/Text/MMark/Extension/LinkTargetSpec.hs
@@ -1,28 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Text.MMark.Extension.LinkTargetSpec (spec) where--import Test.Hspec-import Text.MMark.Extension.LinkTarget-import Text.MMark.Extension.TestUtils--spec :: Spec-spec =-  describe "linkTarget" $ do-    let to = withExt linkTarget-    context "when no link title provided" $-      it "has no effect" $-        "[link](/url)" `to` "<p><a href=\"/url\">link</a></p>\n"-    context "when link title does not start with a target" $-      it "has no effect" $-        "[link](/url 'something _blank')"-          `to` "<p><a href=\"/url\" title=\"something _blank\">link</a></p>\n"-    context "when link title starts with a target" $ do-      context "when there is nothing but the target in title" $-        it "works as intended, no title attribute produced" $-          "[link](/url '_blank')"-            `to` "<p><a target=\"_blank\" href=\"/url\">link</a></p>\n"-      context "when there is also a title" $-        it "works as intended, target is stripped from the title" $-          "[link](/url '_blank something')"-            `to` "<p><a target=\"_blank\" href=\"/url\" title=\"something\">link</a></p>\n"
tests/Text/MMark/Extension/MathJaxSpec.hs view
@@ -11,10 +11,10 @@   describe "mathJax" $ do     let to = withExt (mathJax Nothing)         to' = withExt (mathJax (Just '$'))-    context "when span char is not specified" $-      it "transforms all code spans correctly" $-        "I've got `foo`."-          `to` "<p>I&#39;ve got <span class=\"math inline\">\\(foo\\)</span>.</p>\n"+    context "when span char is not specified"+      $ it "transforms all code spans correctly"+      $ "I've got `foo`."+        `to` "<p>I&#39;ve got <span class=\"math inline\">\\(foo\\)</span>.</p>\n"     context "when span char is specified" $ do       it "does not affect mismatching code spans" $         "I've got `foo`."@@ -22,16 +22,16 @@       it "transforms matching code spans correctly" $         "I've got `$foo$`."           `to'` "<p>I&#39;ve got <span class=\"math inline\">\\(foo\\)</span>.</p>\n"-    context "when code block is not labelled with \"mathjax\"" $-      it "does not affect it" $-        "```\nfoo\n```\n"-          `to` "<pre><code>foo\n</code></pre>\n"+    context "when code block is not labelled with \"mathjax\""+      $ it "does not affect it"+      $ "```\nfoo\n```\n"+        `to` "<pre><code>foo\n</code></pre>\n"     context "when code block is labelled with \"mathjax\"" $ do-      context "when code block contains a single line" $-        it "renders it correctly" $-          "```mathjax\nfoo\n```\n"-            `to` "<p><span class=\"math display\">\\[foo\\]</span></p>\n"-      context "when code block contains multiple lines" $-        it "renders it correctly" $-          "```mathjax\nfoo\nbar\n```\n"-            `to` "<p><span class=\"math display\">\\[foo\\]</span><span class=\"math display\">\\[bar\\]</span></p>\n"+      context "when code block contains a single line"+        $ it "renders it correctly"+        $ "```mathjax\nfoo\n```\n"+          `to` "<p><span class=\"math display\">\\[foo\\]</span></p>\n"+      context "when code block contains multiple lines"+        $ it "renders it correctly"+        $ "```mathjax\nfoo\nbar\n```\n"+          `to` "<p><span class=\"math display\">\\[foo\\]</span><span class=\"math display\">\\[bar\\]</span></p>\n"
+ tests/Text/MMark/Extension/MermaidSpec.hs view
@@ -0,0 +1,37 @@+{-# LANGUAGE OverloadedStrings #-}++module Text.MMark.Extension.MermaidSpec (spec) where++import Data.Map.Strict qualified as M+import Data.Text.Lazy qualified as TL+import Lucid qualified as L+import Test.Hspec+import Text.MMark qualified as MMark+import Text.MMark.Extension.Mermaid+import Text.MMark.Extension.TestUtils++spec :: Spec+spec = do+  describe "mermaid" $ do+    it "renders a mermaid block for the browser" $+      withExt+        mermaid+        "```mermaid\ngraph TD;\n```"+        "<pre class=\"mermaid\">graph TD;\n</pre>\n"+    it "leaves another code block alone" $+      withExt+        mermaid+        "```haskell\nmain\n```"+        "<pre><code class=\"language-haskell\">main\n</code></pre>\n"+  describe "mermaidScanner and mermaidSvg" $ do+    it "puts the rendered diagram in place of the block" $ do+      Right doc <- pure (MMark.parse "" "```mermaid\ngraph TD;\n```")+      let svgs = M.map (const "<svg/>") (MMark.runScanner mermaidScanner doc)+      render (mermaidSvg svgs) doc+        `shouldBe` "<figure class=\"mermaid\"><svg/></figure>\n"+    it "leaves a block with no diagram as its source" $ do+      Right doc <- pure (MMark.parse "" "```mermaid\ngraph TD;\n```")+      render (mermaidSvg M.empty) doc+        `shouldBe` "<pre><code class=\"language-mermaid\">graph TD;\n</code></pre>\n"+  where+    render e = TL.toStrict . L.renderText . MMark.render e
+ tests/Text/MMark/Extension/MetadataSpec.hs view
@@ -0,0 +1,29 @@+{-# LANGUAGE OverloadedStrings #-}++module Text.MMark.Extension.MetadataSpec (spec) where++import Test.Hspec+import Text.MMark qualified as MMark+import Text.MMark.Extension.Metadata+import Text.URI qualified as URI++spec :: Spec+spec = describe "metadataScanner" $ do+  it "finds the title, the lead, and the first image" $ do+    m <- scan "# Title\n\nThe lead here.\n\n![pic](/p.png)\n\nMore."+    metaTitle m `shouldBe` Just "Title"+    metaLead m `shouldBe` Just "The lead here."+    fmap URI.render (metaImage m) `shouldBe` Just "/p.png"+  it "counts words" $ do+    m <- scan "one two three four five"+    metaWords m `shouldBe` 5+  it "rounds the reading time up and never gives zero" $ do+    m <- scan "one two"+    readingTime 200 m `shouldBe` 1+  it "keeps the first of each thing" $ do+    m <- scan "# One\n\n# Two"+    metaTitle m `shouldBe` Just "One"+  where+    scan input = do+      Right doc <- pure (MMark.parse "" input)+      pure (MMark.runScanner metadataScanner doc)
− tests/Text/MMark/Extension/ObfuscateEmailSpec.hs
@@ -1,21 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--module Text.MMark.Extension.ObfuscateEmailSpec (spec) where--import Test.Hspec-import Text.MMark.Extension.ObfuscateEmail-import Text.MMark.Extension.TestUtils--spec :: Spec-spec =-  describe "obfuscateEmail" $ do-    let to = withExt (obfuscateEmail "foo")-    context "when URI has the mailto scheme" $-      it "produces the correct HTML" $-        "<mailto:me@example.org>" `to` "<p><a class=\"foo\" data-email=\"me@example.org\" href=\"javascript:void%280%29\">Enable JavaScript to see this email</a></p>\n"-    context "when URI has some other scheme" $-      it "produces the correct HTML" $-        "<https:example.org>" `to` "<p><a href=\"https:example.org\">https:example.org</a></p>\n"-    context "other elements" $-      it "not affected" $-        "Something." `to` "<p>Something.</p>\n"
+ tests/Text/MMark/Extension/PermalinksSpec.hs view
@@ -0,0 +1,47 @@+{-# LANGUAGE OverloadedStrings #-}++module Text.MMark.Extension.PermalinksSpec (spec) where++import Lucid+import Test.Hspec+import Text.MMark.Extension.Permalinks+import Text.MMark.Extension.TestUtils++spec :: Spec+spec = do+  describe "permalinks" $ do+    it "adds a link to the heading id" $+      withExt+        permalinks+        "# Title"+        "<h1 id=\"title\">Title<a href=\"#title\" class=\"permalink\" aria-hidden=\"true\" tabindex=\"-1\">#</a></h1>\n"+    it "works for every level" $+      withExt+        permalinks+        "###### Deep"+        "<h6 id=\"deep\">Deep<a href=\"#deep\" class=\"permalink\" aria-hidden=\"true\" tabindex=\"-1\">#</a></h6>\n"+    it "leaves other blocks alone" $+      withExt permalinks "Just text." "<p>Just text.</p>\n"+  describe "permalinksWith" $ do+    it "can be given another class and label" $+      withExt+        (permalinksWith (const True) "anchor" Nothing "\182")+        "# T"+        "<h1 id=\"t\">T<a href=\"#t\" class=\"anchor\" aria-hidden=\"true\" tabindex=\"-1\">\182</a></h1>\n"+    it "labels the link with the markup it is given" $+      withExt+        (permalinksWith (const True) "anchor" Nothing (toHtmlRaw ("<svg id=\"a\"></svg>" :: String)))+        "# T"+        "<h1 id=\"t\">T<a href=\"#t\" class=\"anchor\" aria-hidden=\"true\" tabindex=\"-1\"><svg id=\"a\"></svg></a></h1>\n"+    it "keeps a link a screen reader is told about" $+      withExt+        (permalinksWith (const True) "anchor" (Just "Link to this section") "#")+        "# T"+        "<h1 id=\"t\">T<a href=\"#t\" class=\"anchor\" aria-label=\"Link to this section\">#</a></h1>\n"+    it "gives a link only to the levels it is told to" $ do+      withExt (permalinksWith (\n -> n >= 2 && n <= 4) "anchor" Nothing "#") "# T" "<h1 id=\"t\">T</h1>\n"+      withExt+        (permalinksWith (\n -> n >= 2 && n <= 4) "anchor" Nothing "#")+        "## T"+        "<h2 id=\"t\">T<a href=\"#t\" class=\"anchor\" aria-hidden=\"true\" tabindex=\"-1\">#</a></h2>\n"+      withExt (permalinksWith (\n -> n >= 2 && n <= 4) "anchor" Nothing "#") "##### T" "<h5 id=\"t\">T</h5>\n"
tests/Text/MMark/Extension/PunctuationPrettifierSpec.hs view
@@ -9,7 +9,7 @@ spec :: Spec spec =   describe "punctuationPrettifier" $ do-    let to = withExt punctuationPrettifier+    let to = withTrans punctuationPrettifier     context "on plain inlines" $ do       it "replaces ... with ellipsis" $         "He forgot where he came from..." `to` "<p>He forgot where he came from…</p>\n"@@ -31,6 +31,6 @@         "Something-\"foo\"." `to` "<p>Something-”foo”.</p>\n"       it "a tricky test 2" $         "Something.--" `to` "<p>Something.–</p>\n"-    context "on other inlines" $-      it "has no effect" $-        "`code -- span`" `to` "<p><code>code -- span</code></p>\n"+    context "on other inlines"+      $ it "has no effect"+      $ "`code -- span`" `to` "<p><code>code -- span</code></p>\n"
tests/Text/MMark/Extension/SkylightingSpec.hs view
@@ -2,6 +2,7 @@  module Text.MMark.Extension.SkylightingSpec (spec) where +import Data.Text qualified as T import Test.Hspec import Text.MMark.Extension.Skylighting import Text.MMark.Extension.TestUtils@@ -10,11 +11,45 @@ spec =   describe "skylighting" $ do     let to = withExt skylighting-    context "when info string does not result in a successful lookup" $-      it "has no effect" $-        "```foo\nmain :: IO ()\nmain = return ()\n```\n"+    context "when info string does not result in a successful lookup"+      $ it "has no effect"+      $ "```foo\nmain :: IO ()\nmain = return ()\n```\n"+        `to` "<pre><code class=\"language-foo\">main :: IO ()\nmain = return ()\n</code></pre>\n"+    context "with info string results in a successful lookup"+      $ it "renders it correctly"+      $ "```haskell\nmain :: IO ()\nmain = return ()\n```\n"+        `to` "<div class=\"source-code\"><pre><code class=\"language-haskell\"><span class=\"ot\">main ::</span><span> </span><span class=\"dt\">IO</span><span> ()</span>\n<span>main </span><span class=\"ot\">=</span><span> </span><span class=\"fu\">return</span><span> ()</span>\n</code></pre></div>\n"+    context "when the info string ends with a line specification" $ do+      it "still recognizes the language, and points at the line" $+        "```haskell {2}\nmain :: IO ()\nmain = return ()\n```\n"+          `to` T.concat+            [ "<div class=\"source-code\"><pre><code class=\"language-haskell\">",+              "<span class=\"ot\">main ::</span><span> </span><span class=\"dt\">IO</span><span> ()</span>\n",+              "<span class=\"highlighted-line\">",+              "<span>main </span><span class=\"ot\">=</span><span> </span><span class=\"fu\">return</span><span> ()</span>\n",+              "</span>",+              "</code></pre></div>\n"+            ]+      it "points at every line a range names" $+        "```haskell {1-2}\nmain :: IO ()\nmain = return ()\n```\n"+          `to` T.concat+            [ "<div class=\"source-code\"><pre><code class=\"language-haskell\">",+              "<span class=\"highlighted-line\">",+              "<span class=\"ot\">main ::</span><span> </span><span class=\"dt\">IO</span><span> ()</span>\n",+              "</span>",+              "<span class=\"highlighted-line\">",+              "<span>main </span><span class=\"ot\">=</span><span> </span><span class=\"fu\">return</span><span> ()</span>\n",+              "</span>",+              "</code></pre></div>\n"+            ]+      it "does not take a specification that names no line for one" $+        -- there is no such thing as pointing at nothing, so this is a+        -- malformed info string and the whole of it names the language+        "```haskell {}\nmain :: IO ()\nmain = return ()\n```\n"+          `to` "<pre><code class=\"language-haskell\">main :: IO ()\nmain = return ()\n</code></pre>\n"+      it "leaves a block alone when the language is still not one it knows" $+        "```foo {1}\nmain :: IO ()\nmain = return ()\n```\n"           `to` "<pre><code class=\"language-foo\">main :: IO ()\nmain = return ()\n</code></pre>\n"-    context "with info string results in a successful lookup" $-      it "renders it correctly" $-        "```haskell\nmain :: IO ()\nmain = return ()\n```\n"+      it "ignores a line the block does not have" $+        "```haskell {9}\nmain :: IO ()\nmain = return ()\n```\n"           `to` "<div class=\"source-code\"><pre><code class=\"language-haskell\"><span class=\"ot\">main ::</span><span> </span><span class=\"dt\">IO</span><span> ()</span>\n<span>main </span><span class=\"ot\">=</span><span> </span><span class=\"fu\">return</span><span> ()</span>\n</code></pre></div>\n"
tests/Text/MMark/Extension/TableOfContentsSpec.hs view
@@ -2,25 +2,54 @@  module Text.MMark.Extension.TableOfContentsSpec (spec) where -import qualified Data.Text.IO as TIO-import qualified Data.Text.Lazy as TL-import qualified Lucid as L+import Data.Text (Text)+import Data.Text.IO qualified as TIO+import Data.Text.Lazy qualified as TL+import Lucid qualified as L import Test.Hspec-import qualified Text.MMark as MMark+import Text.MMark qualified as MMark import Text.MMark.Extension.TableOfContents+import Text.MMark.Extension.TestUtils (summarize)+import Text.Megaparsec (errorBundlePretty)  spec :: Spec spec =-  describe "toc" $+  describe "toc" $ do     it "works" $ do       input <- TIO.readFile "data/toc.md"       expected <- TIO.readFile "data/toc.html"       Right doc <- pure (MMark.parse "" input)-      let headings = MMark.runScanner doc (tocScanner (> 1))-          actual =-            TL.toStrict-              . L.renderText-              . MMark.render-              . MMark.useExtension (toc "toc" headings)-              $ doc-      actual `shouldBe` expected+      let headings = MMark.runScanner (tocScanner (> 1)) doc+      case MMark.runTrans (toc "toc" headings) doc of+        Left errs -> expectationFailure (errorBundlePretty errs)+        Right doc' ->+          (TL.toStrict . L.renderText . MMark.render mempty) doc'+            `shouldBe` expected+    it "leaves a code block with another label alone" $+      withToc (> 0) "toc" "# A\n\n```haskell\nx = 1\n```\n"+        `shouldBe` Right "<h1 id=\"a\">A</h1>\n<pre><code class=\"language-haskell\">x = 1\n</code></pre>\n"+    it "uses the label it is given" $+      withToc (> 0) "contents" "# A\n\n```contents\n```\n"+        `shouldBe` Right "<h1 id=\"a\">A</h1>\n<ul>\n<li>\n<a href=\"#a\">A</a>\n</li>\n</ul>\n"+    it "reports a table of contents with nothing to put in it" $+      withToc (> 1) "toc" "# A\n\n```toc\n```\n"+        `shouldBe` Left ["3:1: there are no headings to put in the table of contents"]+    it "reports a table of contents in a document with no headings at all" $+      withToc (> 0) "toc" "Some text.\n\n```toc\n```\n"+        `shouldBe` Left ["3:1: there are no headings to put in the table of contents"]+    it "says nothing about a document that asks for no table of contents" $+      withToc (> 1) "toc" "# A\n\nSome text.\n"+        `shouldBe` Right "<h1 id=\"a\">A</h1>\n<p>Some text.</p>\n"++-- | Build a table of contents out of the headings the predicate admits and+-- put it where the given label asks, giving either the problems reported or+-- the rendered document.+withToc :: (Int -> Bool) -> Text -> Text -> Either [Text] Text+withToc p label input =+  case MMark.parse "" input of+    Left _ -> error "the test input does not parse"+    Right doc ->+      case MMark.runTrans (toc label (MMark.runScanner (tocScanner p) doc)) doc of+        Left errs -> Left (summarize (errorBundlePretty errs))+        Right doc' ->+          Right (TL.toStrict (L.renderText (MMark.render mempty doc')))
tests/Text/MMark/Extension/TestUtils.hs view
@@ -1,19 +1,39 @@+{-# LANGUAGE OverloadedStrings #-}+ module Text.MMark.Extension.TestUtils   ( withExt,+    withTrans,+    transErrors,+    transErrorsM,+    checkErrors,+    summarize,+    withTempDir,   ) where +import Control.Exception (bracket)+import Data.Char (isDigit) import Data.Text (Text)-import qualified Data.Text.Lazy as TL-import qualified Lucid as L+import Data.Text qualified as T+import Data.Text.Lazy qualified as TL+import Lucid qualified as L+import System.Directory+  ( createDirectory,+    getTemporaryDirectory,+    removeDirectoryRecursive,+    removeFile,+  )+import System.IO (hClose, openTempFile) import Test.Hspec-import qualified Text.MMark as MMark+import Text.MMark qualified as MMark+import Text.MMark.Trans (Bni, Trans, TransT)+import Text.Megaparsec (errorBundlePretty) --- | Feed input into MMark parser, apply an extension, render the parsed--- document and demand that it matches the given example.+-- | Feed input into MMark parser, apply a render extension, render the+-- parsed document and demand that it matches the given example. withExt ::-  -- | MMark extension to use-  MMark.Extension ->+  -- | Render extension to use+  MMark.RenderExtension ->   -- | Input for the parser   Text ->   -- | Expected output of the render@@ -21,10 +41,94 @@   Expectation withExt ext input expected = do   Right doc <- pure (MMark.parse "" input)-  let actual =-        TL.toStrict-          . L.renderText-          . MMark.render-          . MMark.useExtension ext-          $ doc-  actual `shouldBe` expected+  render mempty doc `shouldBe` expected+  where+    render e = TL.toStrict . L.renderText . MMark.render (e <> ext)++-- | Like 'withExt', but applies a transformation instead.+withTrans ::+  -- | Transformation to apply+  (Bni -> Trans Bni) ->+  -- | Input for the parser+  Text ->+  -- | Expected output of the render+  Text ->+  Expectation+withTrans f input expected = do+  Right doc <- pure (MMark.parse "" input)+  case MMark.runTrans f doc of+    Left errs -> expectationFailure (errorBundlePretty errs)+    Right doc' ->+      (TL.toStrict . L.renderText . MMark.render mempty) doc'+        `shouldBe` expected++-- | Apply a transformation that is expected to report problems and return+-- one @line:col: message@ string per problem.+transErrors ::+  -- | Transformation to apply+  (Bni -> Trans Bni) ->+  -- | Input for the parser+  Text ->+  IO [Text]+transErrors f input = do+  Right doc <- pure (MMark.parse "" input)+  pure $ case MMark.runTrans f doc of+    Right _ -> []+    Left errs -> summarize (errorBundlePretty errs)++-- | Like 'transErrors', but for a transformation that needs 'IO'.+transErrorsM ::+  -- | Transformation to apply+  (Bni -> TransT IO Bni) ->+  -- | Input for the parser+  Text ->+  IO [Text]+transErrorsM f input = do+  Right doc <- pure (MMark.parse "" input)+  r <- MMark.runTransM f doc+  pure $ case r of+    Right _ -> []+    Left errs -> summarize (errorBundlePretty errs)++-- | Reduce a rendered error bundle to one @line:col: message@ string per+-- error, dropping the source excerpt megaparsec prints in between.+summarize :: String -> [Text]+summarize = go Nothing . fmap T.strip . T.lines . T.pack+  where+    go _ [] = []+    go cur (l : ls)+      | T.null l = go cur ls+      | isPos l = go (Just l) ls+      | "|" `T.isInfixOf` l = go cur ls+      | otherwise = case cur of+          Just p -> (p <> " " <> l) : go Nothing ls+          Nothing -> go Nothing ls+    isPos t = ":" `T.isSuffixOf` t && T.all (\c -> isDigit c || c == ':') t++-- | Run a check that is expected to report problems and return one+-- @line:col: message@ string per problem.+checkErrors ::+  -- | Check to run+  Trans a ->+  -- | Input for the parser+  Text ->+  IO [Text]+checkErrors c input = do+  Right doc <- pure (MMark.parse "" input)+  pure $ case MMark.runCheck c doc of+    Right _ -> []+    Left errs -> summarize (errorBundlePretty errs)++-- | Run an action in a fresh empty directory, which is removed afterwards.+withTempDir :: (FilePath -> IO a) -> IO a+withTempDir = bracket acquire removeDirectoryRecursive+  where+    -- 'openTempFile' is the only way base offers to get a name nothing else+    -- has taken, so take one and swap the file for a directory.+    acquire = do+      tmp <- getTemporaryDirectory+      (path, h) <- openTempFile tmp "mmark-ext-test"+      hClose h+      removeFile path+      createDirectory path+      return path