packages feed

okf-core-0.1.0.0: src/Okf/Index.hs

-- | Deterministic Markdown index rendering for OKF bundle directories.
module Okf.Index
  ( renderBundleIndexes,
    renderIndex,
    writeBundleIndexes,
  )
where

import Data.List qualified as List
import Data.Map.Strict qualified as Map
import Data.Text qualified as Text
import Data.Text.IO qualified as Text.IO
import Okf.Bundle
import Okf.Prelude
import System.Directory
  ( doesDirectoryExist,
    listDirectory,
  )
import System.FilePath ((</>))
import System.FilePath qualified as FilePath

-- | Render an @index.md@ for one bundle directory from its immediate concepts
-- and subdirectory names.
renderIndex :: [FilePath] -> [Concept] -> Text
renderIndex subdirectories concepts =
  Text.intercalate "\n" (filter (not . Text.null) [subdirectorySection, conceptSections]) <> "\n"
  where
    sortedSubdirectories = List.sort subdirectories
    subdirectorySection
      | null sortedSubdirectories = ""
      | otherwise =
          Text.unlines
            ( "# Subdirectories"
                : ""
                : (directoryBullet <$> sortedSubdirectories)
            )

    groupedConcepts = Map.toAscList (foldr addConcept Map.empty concepts)
    conceptSections =
      Text.intercalate "\n" (sectionForType <$> groupedConcepts)

addConcept :: Concept -> Map.Map Text [Concept] -> Map.Map Text [Concept]
addConcept concept =
  Map.insertWith (<>) (conceptType concept) [concept]

sectionForType :: (Text, [Concept]) -> Text
sectionForType (typeName, concepts) =
  Text.unlines
    ( ("# " <> typeName)
        : ""
        : (conceptBullet <$> List.sortOn conceptSourcePath concepts)
    )

directoryBullet :: FilePath -> Text
directoryBullet directory =
  "- [" <> Text.pack directory <> "/](" <> Text.pack directory <> "/index.md)"

conceptBullet :: Concept -> Text
conceptBullet concept =
  "- ["
    <> fromMaybe (Text.pack (FilePath.dropExtension (FilePath.takeFileName (conceptSourcePath concept)))) (conceptTitle concept)
    <> "]("
    <> Text.pack (FilePath.takeFileName (conceptSourcePath concept))
    <> ")"
    <> maybe "" (" - " <>) (conceptDescription concept)

-- | Write deterministic @index.md@ files for every directory in a bundle.
writeBundleIndexes :: FilePath -> IO (Either BundleError ())
writeBundleIndexes root = do
  rendered <- renderBundleIndexes root
  case rendered of
    Left bundleError -> pure (Left bundleError)
    Right indexes -> do
      mapM_ (\(relativePath, content) -> Text.IO.writeFile (root </> relativePath) content) indexes
      pure (Right ())

-- | Render every @index.md@ file that would be written for a bundle.
renderBundleIndexes :: FilePath -> IO (Either BundleError [(FilePath, Text)])
renderBundleIndexes root = do
  walked <- walkBundle root
  case walked of
    Left bundleError -> pure (Left bundleError)
    Right concepts -> do
      directories <- indexDirectories root concepts
      indexes <- mapM (renderDirectoryIndex root concepts) directories
      pure (Right indexes)

indexDirectories :: FilePath -> [Concept] -> IO [FilePath]
indexDirectories root concepts = do
  discovered <- discoverDirectories root ""
  let conceptDirectories = List.nub (FilePath.takeDirectory . conceptSourcePath <$> concepts)
  pure (List.sort (List.nub ("" : discovered <> conceptDirectories)))

discoverDirectories :: FilePath -> FilePath -> IO [FilePath]
discoverDirectories root relativeDir = do
  entries <- List.sort <$> listDirectory (root </> relativeDir)
  fmap concat $
    mapM
      ( \entry -> do
          let relativePath = FilePath.normalise (relativeDir </> entry)
              absolutePath = root </> relativePath
          isDirectory <- doesDirectoryExist absolutePath
          if isDirectory
            then (relativePath :) <$> discoverDirectories root relativePath
            else pure []
      )
      entries

renderDirectoryIndex :: FilePath -> [Concept] -> FilePath -> IO (FilePath, Text)
renderDirectoryIndex root concepts relativeDir = do
  subdirectories <- immediateSubdirectories root relativeDir
  let immediateConcepts =
        List.filter
          (\concept -> FilePath.normalise (FilePath.takeDirectory (conceptSourcePath concept)) == FilePath.normalise relativeDir)
          concepts
      indexPath = relativeDir </> "index.md"
  pure (FilePath.normalise indexPath, renderIndex subdirectories immediateConcepts)

immediateSubdirectories :: FilePath -> FilePath -> IO [FilePath]
immediateSubdirectories root relativeDir = do
  entries <- List.sort <$> listDirectory (root </> relativeDir)
  fmap concat $
    mapM
      ( \entry -> do
          isDirectory <- doesDirectoryExist (root </> relativeDir </> entry)
          pure [entry | isDirectory]
      )
      entries