shikumi-okf-0.1.0.0: src/Shikumi/Okf/Generate.hs
-- | Turn a 'ProgramManifest' into OKF concepts and write them to disk.
--
-- The bundle is one @Shikumi App@ concept that links to one @Shikumi Program@
-- concept per manifest entry. Those Markdown links are the app→program edges the
-- OKF graph extractor reads, so a reader (or the @okf graph@ command) can answer
-- "which programs does this app ship?". Building concepts is pure and can fail
-- only on an invalid concept name (an author error in the manifest), surfaced as
-- 'GenerateError'; writing performs IO through the @okf-core@ producer API.
--
-- Generation is deterministic: the timestamp is an explicit argument, never read
-- from the wall clock, so regenerating an unchanged manifest yields byte-identical
-- output and a "regenerate and diff" check stays meaningful.
module Shikumi.Okf.Generate
( GenerateError (..),
programConceptId,
appConceptId,
programConcept,
appConcept,
generateBundle,
writeProgramBundle,
)
where
import Data.Bifunctor (first)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Okf.Bundle (BundleError, Concept, conceptFromDocument, writeBundle)
import Okf.ConceptId (ConceptId, ConceptIdError, parseConceptId, renderConceptLink)
import Okf.Document
( Frontmatter,
OKFDocument (..),
OkfCommon (..),
okfCommon,
setResource,
setTags,
)
import Okf.Index (writeBundleIndexes)
import Shikumi.Okf.Render (renderProgramBody)
import Shikumi.Okf.Types (AppInfo (..), ProgramDoc (..), ProgramManifest (..))
-- | Why bundle generation failed.
data GenerateError
= -- | A manifest name did not form a valid OKF concept id (carries the raw
-- @apps/<app>@ or @programs/<name>@ text and the underlying parse error).
InvalidConceptName Text ConceptIdError
| -- | Writing the bundle's @index.md@ files failed.
IndexWriteError BundleError
deriving stock (Eq, Show)
-- | The concept id of a program: @programs/<name>@.
programConceptId :: ProgramDoc -> Either GenerateError ConceptId
programConceptId doc = mkConceptId ("programs/" <> name doc)
-- | The concept id of the application: @apps/<app>@.
appConceptId :: AppInfo -> Either GenerateError ConceptId
appConceptId app = mkConceptId ("apps/" <> appName app)
mkConceptId :: Text -> Either GenerateError ConceptId
mkConceptId raw = first (InvalidConceptName raw) (parseConceptId raw)
-- | Build the @Shikumi Program@ concept for one documented program.
programConcept :: AppInfo -> Maybe Text -> ProgramDoc -> Either GenerateError Concept
programConcept app timestamp doc = do
cid <- programConceptId doc
let resource =
"shikumi://" <> appNamespace app <> "/" <> appName app <> "/programs/" <> name doc
frontmatter =
applyTags (tags doc) . setResource resource $
okfCommon
OkfCommon
{ commonType = "Shikumi Program",
commonTitle = Just (fromMaybe (name doc) (title doc)),
commonDescription = description doc,
commonTimestamp = timestamp
}
okfDoc = OKFDocument {frontmatter, body = renderProgramBody doc}
pure (conceptFromDocument cid okfDoc)
-- | Build the @Shikumi App@ concept that links to every program.
appConcept :: AppInfo -> Maybe Text -> ProgramManifest -> Either GenerateError Concept
appConcept app timestamp (ProgramManifest docs) = do
cid <- appConceptId app
links <- traverse programLink docs
let appLabel = fromMaybe (appName app) (appTitle app)
resource = "shikumi://" <> appNamespace app <> "/" <> appName app
bodyLines =
["# " <> appLabel]
<> maybe [] (\d -> ["", d]) (appDescription app)
<> ["", "## Programs", ""]
<> links
frontmatter =
setResource resource $
okfCommon
OkfCommon
{ commonType = "Shikumi App",
commonTitle = Just appLabel,
commonDescription = appDescription app,
commonTimestamp = timestamp
}
okfDoc = OKFDocument {frontmatter, body = T.unlines bodyLines}
pure (conceptFromDocument cid okfDoc)
where
programLink doc = do
pcid <- programConceptId doc
pure ("- " <> renderConceptLink pcid (fromMaybe (name doc) (title doc)))
-- | All concepts for a manifest: the app concept first, then one per program.
generateBundle :: AppInfo -> Maybe Text -> ProgramManifest -> Either GenerateError [Concept]
generateBundle app timestamp manifest = do
appC <- appConcept app timestamp manifest
programCs <- traverse (programConcept app timestamp) (entries manifest)
pure (appC : programCs)
-- | Generate the bundle and write it to @root@, then write its @index.md@ files.
-- Returns the first error encountered (an invalid name before any IO, or an index
-- write failure after the concept files are written).
writeProgramBundle ::
FilePath -> AppInfo -> Maybe Text -> ProgramManifest -> IO (Either GenerateError ())
writeProgramBundle root app timestamp manifest =
case generateBundle app timestamp manifest of
Left err -> pure (Left err)
Right concepts -> do
writeBundle root concepts
indexResult <- writeBundleIndexes root
pure (first IndexWriteError indexResult)
-- | Attach a @tags@ field only when there is at least one tag, so a program with
-- no tags does not emit an empty @tags: []@ list.
applyTags :: [Text] -> Frontmatter -> Frontmatter
applyTags [] frontmatter = frontmatter
applyTags ts frontmatter = setTags ts frontmatter