packages feed

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

-- | Safe concept identifiers for bundle-relative OKF Markdown documents.
module Okf.ConceptId
  ( ConceptId
  , ConceptIdError (..)
  , conceptIdFromFilePath
  , conceptIdToFilePath
  , parseConceptId
  , renderConceptId
  , renderConceptLinkTarget
  , renderConceptLink
  ) where

import Data.Char qualified as Char
import Data.Aeson (ToJSON (..))
import Data.List.NonEmpty qualified as NonEmpty
import Data.Text qualified as Text
import System.FilePath ((<.>))
import System.FilePath qualified as FilePath

import Okf.Prelude hiding ((<.>))

-- | A bundle-relative concept path without the @.md@ suffix.
newtype ConceptId = ConceptId
  { segments :: NonEmpty Text
  }
  deriving stock (Generic, Eq, Ord, Show)

instance ToJSON ConceptId where
  toJSON = toJSON . renderConceptId

-- | Why a piece of text could not become a 'ConceptId'.
data ConceptIdError
  = EmptyConceptId
  | InvalidConceptIdSegment Text
  deriving stock (Generic, Eq, Show)

-- | Parse a slash-separated concept identifier such as @tables/users@.
parseConceptId :: Text -> Either ConceptIdError ConceptId
parseConceptId raw =
  case Text.splitOn "/" raw of
    [] -> Left EmptyConceptId
    [""] -> Left EmptyConceptId
    firstSegment : rest -> do
      parsedSegments <- traverse validateSegment (firstSegment :| rest)
      pure (ConceptId parsedSegments)

-- | Render a concept identifier without a file extension.
renderConceptId :: ConceptId -> Text
renderConceptId (ConceptId rawSegments) =
  Text.intercalate "/" (NonEmpty.toList rawSegments)

-- | Convert a concept identifier to a bundle-relative Markdown path.
conceptIdToFilePath :: ConceptId -> FilePath
conceptIdToFilePath (ConceptId rawSegments) =
  FilePath.joinPath (Text.unpack <$> NonEmpty.toList rawSegments) <.> "md"

-- | Convert a bundle-relative Markdown path to a concept identifier.
conceptIdFromFilePath :: FilePath -> Either ConceptIdError ConceptId
conceptIdFromFilePath path =
  parseConceptId (Text.pack (FilePath.dropExtension (FilePath.normalise path)))

-- | The canonical bundle-absolute Markdown link target for a concept, e.g.
-- @/modules/nix-haskell-flake.md@. A link whose URL is this string resolves
-- back to the same 'ConceptId' regardless of which document contains it.
renderConceptLinkTarget :: ConceptId -> Text
renderConceptLinkTarget conceptId =
  "/" <> Text.replace "\\" "/" (Text.pack (conceptIdToFilePath conceptId))

-- | A complete Markdown link to a concept: @[label](/path.md)@. Only the URL is
-- read by OKF link extraction, so an odd label does not break edges, but the
-- caller should choose a label free of unbalanced brackets to keep prose readable.
renderConceptLink :: ConceptId -> Text -> Text
renderConceptLink conceptId label =
  "[" <> label <> "](" <> renderConceptLinkTarget conceptId <> ")"

validateSegment :: Text -> Either ConceptIdError Text
validateSegment segment
  | Text.null segment = Left (InvalidConceptIdSegment segment)
  | Text.any (== '/') segment = Left (InvalidConceptIdSegment segment)
  | otherwise =
      case Text.uncons segment of
        Nothing -> Left (InvalidConceptIdSegment segment)
        Just (firstChar, rest)
          | isInitialChar firstChar && Text.all isTrailingChar rest -> Right segment
          | otherwise -> Left (InvalidConceptIdSegment segment)

isInitialChar :: Char -> Bool
isInitialChar char =
  Char.isAsciiLower char || Char.isAsciiUpper char || Char.isDigit char || char == '_'

isTrailingChar :: Char -> Bool
isTrailingChar char =
  isInitialChar char || char == '.' || char == '-'