packages feed

croque-mort-0.1.0.0: app/Domain.hs

{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE NoFieldSelectors #-}

module Domain
  ( Folder (..),
    RemoteBaseURL (..),
    Link (..),
    originalLink,
    parseLink,
    ParsedLinks (..),
    WithRootFolder (..),
    LinkReport (..),
    LinkStatus (..),
    CheckedLinks (..),
    checkLinks,
    BrokenLinks (..),
    filterBrokenLinks,
    hasBrokenLinks,
    Summary (..),
    SummaryPhase (..),
    mkSummary,
  )
where

import Data.Aeson
import qualified Data.Char as Char
import Data.List (isPrefixOf, isSuffixOf)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Builder as TL
import GHC.Generics
import HTMLEntities.Decoder (htmlEncodedText)
import qualified System.FilePath as Path

newtype Folder
  = Folder {unFolder :: FilePath}
  deriving stock (Eq, Ord, Show, Generic)
  deriving newtype (ToJSON, ToJSONKey)

newtype RemoteBaseURL
  = RemoteBaseURL {unRemoteBaseURL :: T.Text}
  deriving stock (Eq, Ord, Show, Generic)
  deriving newtype (ToJSON, ToJSONKey)

data Link
  = LocalLink T.Text FilePath
  | RemoteLink T.Text T.Text
  deriving stock (Eq, Ord, Show, Generic)

originalLink :: Link -> T.Text
originalLink =
  \case
    LocalLink o _ -> o
    RemoteLink o _ -> o

linkOptions :: Options
linkOptions =
  defaultOptions
    { sumEncoding =
        TaggedObject
          { tagFieldName = "type",
            contentsFieldName = "link"
          }
    }

instance ToJSON Link where
  toJSON = genericToJSON linkOptions
  toEncoding = genericToEncoding linkOptions

parseLink :: T.Text -> Maybe Link
parseLink raw
  | "#" `T.isPrefixOf` raw || T.null (T.strip raw) = Nothing
  | "javascript:" `T.isPrefixOf` raw || "data:" `T.isPrefixOf` raw = Nothing
  | "https://" `T.isPrefixOf` clean || "http://" `T.isPrefixOf` clean = Just $ RemoteLink raw clean
  | otherwise = Just $ LocalLink raw $ T.unpack clean
  where
    clean = T.takeWhile (`notElem` ['#', '?']) $ TL.toStrict $ TL.toLazyText $ htmlEncodedText raw

newtype ParsedLinks
  = ParsedLinks {unParsedLinks :: Map.Map RemoteBaseURL (WithRootFolder (Map.Map FilePath [Link]))}
  deriving stock (Eq, Ord, Show, Generic)
  deriving newtype (ToJSON)

data WithRootFolder a = WithRootFolder
  { root :: Folder,
    links :: a
  }
  deriving stock (Eq, Ord, Show, Generic, Functor)
  deriving anyclass (ToJSON)

data LinkReport = LinkReport
  { link :: Link,
    status :: LinkStatus
  }
  deriving stock (Eq, Ord, Show, Generic)
  deriving anyclass (ToJSON)

data LinkStatus
  = Skipped
  | Valid
  | Broken
  deriving stock (Eq, Ord, Show, Generic)

instance Semigroup LinkStatus where
  Skipped <> x = x
  x <> Skipped = x
  _ <> Valid = Valid
  Valid <> _ = Valid
  Broken <> Broken = Broken

linkStatusOptions :: Options
linkStatusOptions =
  defaultOptions
    { constructorTagModifier = map Char.toLower
    }

instance ToJSON LinkStatus where
  toJSON = genericToJSON linkStatusOptions
  toEncoding = genericToEncoding linkStatusOptions

newtype CheckedLinks
  = CheckedLinks {unCheckedLinks :: Map.Map RemoteBaseURL (WithRootFolder (Map.Map FilePath [LinkReport]))}
  deriving stock (Eq, Ord, Show, Generic)
  deriving newtype (ToJSON)

checkLinks :: ParsedLinks -> CheckedLinks
checkLinks (ParsedLinks links) =
  CheckedLinks $
    Map.map
      ( \withRoot ->
          WithRootFolder
            { root = withRoot.root,
              links = Map.filter (not . null) $ Map.mapWithKey (map . checkLink withRoot.root) withRoot.links
            }
      )
      links
  where
    checkLink (Folder root) file link =
      LinkReport
        { link = link,
          status =
            let fixIndex targetFile
                  | null targetFile = "index.html"
                  | "/" `isSuffixOf` targetFile = targetFile <> "index.html"
                  | otherwise = targetFile
             in case link of
                  LocalLink _ localLink ->
                    let targetFile = fixIndex localLink
                        fullFile =
                          Path.normalise $
                            if "/" `isPrefixOf` targetFile
                              then root Path.</> ("." <> targetFile)
                              else Path.takeDirectory file Path.</> targetFile
                     in if any (Map.member fullFile . (.links)) $ Map.elems links
                          then Valid
                          else Broken
                  RemoteLink _ remoteLink ->
                    let checkRemote (RemoteBaseURL remoteBase) withRoot =
                          flip fmap (T.stripPrefix remoteBase remoteLink) $ \subFile ->
                            let targetFile = fixIndex $ T.unpack $ fromMaybe subFile $ T.stripPrefix "/" subFile
                             in if Map.member (Path.normalise $ withRoot.root.unFolder Path.</> targetFile) withRoot.links
                                  then Valid
                                  else Broken
                     in fromMaybe Skipped $
                          Map.foldlWithKey'
                            (\acc remoteBase withRoot -> acc <> checkRemote remoteBase withRoot)
                            Nothing
                            links
        }

newtype BrokenLinks
  = BrokenLinks {unBrokenLinks :: Map.Map RemoteBaseURL (WithRootFolder (Map.Map FilePath [Link]))}
  deriving stock (Eq, Ord, Show, Generic)
  deriving newtype (ToJSON)

filterBrokenLinks :: CheckedLinks -> BrokenLinks
filterBrokenLinks (CheckedLinks checked) =
  BrokenLinks $
    Map.filter (not . null . (.links)) $
      Map.map (fmap $ Map.filter (not . null) . Map.map (map (.link) . filter ((== Broken) . (.status)))) checked

hasBrokenLinks :: BrokenLinks -> Bool
hasBrokenLinks (BrokenLinks broken) = not $ Map.null broken

data Summary = Summary
  { parsing :: SummaryPhase,
    checking :: SummaryPhase,
    broken :: SummaryPhase
  }
  deriving stock (Eq, Ord, Show, Generic)
  deriving anyclass (ToJSON)

data SummaryPhase = SummaryPhase
  { remoteBaseURLs :: Int,
    files :: Int,
    links :: Int,
    linksSkipped :: Maybe Int,
    linksValid :: Maybe Int,
    linksBroken :: Maybe Int
  }
  deriving stock (Eq, Ord, Show, Generic)
  deriving anyclass (ToJSON)

mkSummary :: ParsedLinks -> CheckedLinks -> BrokenLinks -> Summary
mkSummary (ParsedLinks parsed) (CheckedLinks checked) (BrokenLinks broken) =
  Summary
    { parsing =
        phase parsed $ const (Nothing, Nothing, Nothing),
      checking =
        phase checked $ \allLinks ->
          ( Just $ length $ filter ((== Skipped) . (.status)) allLinks,
            Just $ length $ filter ((== Valid) . (.status)) allLinks,
            Just $ length $ filter ((== Broken) . (.status)) allLinks
          ),
      broken =
        phase broken $ \allLinks -> (Nothing, Nothing, Just $ length allLinks)
    }
  where
    phase ::
      Map.Map RemoteBaseURL (WithRootFolder (Map.Map FilePath [a])) ->
      ([a] -> (Maybe Int, Maybe Int, Maybe Int)) ->
      SummaryPhase
    phase nestedMap getStats =
      let allFiles = map (.links) $ Map.elems nestedMap
          allLinks = concat $ concatMap Map.elems allFiles
          (skippedCount, validCount, brokenCount) = getStats allLinks
       in SummaryPhase
            { remoteBaseURLs = Map.size nestedMap,
              files = sum $ map Map.size allFiles,
              links = length allLinks,
              linksSkipped = skippedCount,
              linksValid = validCount,
              linksBroken = brokenCount
            }