packages feed

mmark-ext-0.3.0.0: Text/MMark/Extension/Footnotes.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-}

-- |
-- Module      :  Text.MMark.Extension.Footnotes
-- Copyright   :  © 2018–present Mark Karpov
-- License     :  BSD 3 clause
--
-- Maintainer  :  Mark Karpov <markkarpov92@gmail.com>
-- Stability   :  experimental
-- Portability :  portable
--
-- An extension to add footnotes to your documents.
--
-- @since 0.1.1.0
module Text.MMark.Extension.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 Data.List.NonEmpty qualified as NE
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as M
import Data.Text (Text)
import Data.Text qualified as T
import Lens.Micro ((^.))
import Lucid
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
--       piece consisting of a number into links to footnote references.
--     * It turns block quotes with the @\"footnotes\"@ label (see the
--       example below) into a footnote section.
--
-- > Here goes some text [1](footnote:1).
-- >
-- > > footnotes
-- > >
-- > > 1. Here we have the footnote.
--
-- 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 :: RenderExtension
footnoteRefs = Render.inlineRender $ \old inline ->
  case inline of
    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 :: RenderExtension
footnoteSection = Render.blockRender $ \old block ->
  case block of
    b@(Blockquote _ [Paragraph _ (pOis, _), OrderedList _ i items]) ->
      if Render.asPlainText (getOis pOis) == footnoteLabel
        then do
          let startIndex = [start_ (renderIx i) | i /= 1]
          ol_ startIndex $ do
            newline
            forM_ (NE.zip (NE.iterate (+ 1) i) items) $ \(j, x) -> do
              let j' = renderIx j
              li_ [id_ (footnoteId j')] $ do
                newline
                mapM_ old x
                a_ [fragmentHref (referenceId j')] "↩"
              newline
          newline
        else old b
    other -> old other
  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 . Render.headerFragment

footnoteId :: Text -> Text
footnoteId x = "fn" <> x

referenceId :: Text -> Text
referenceId x = "fnref" <> x