packages feed

keiki-0.1.0.0: src/Keiki/Render/Markdown.hs

{-# LANGUAGE MultiWayIf #-}

-- | Generic Markdown marker-replacement helper. References no keiki
-- type: it rewrites a marked fenced block inside an arbitrary Markdown
-- document. The marker convention is a matched pair of HTML comments,
-- @\<!-- {namespace}: {id} begin --\>@ and @\<!-- {namespace}: {id} end --\>@.
--
-- This closes the loop on regenerating a diagram document: a section
-- emitted by @'Keiki.Render.Mermaid.toMermaidAtlasWith'@ with
-- @atlasWrapMarkers = Just ns@ produces exactly these markers (keyed by the
-- section's @sectionId@), so 'replaceMarkdownDiagramBlock' can later refresh
-- that one block in place while preserving every byte outside it.
--
-- See @docs/plans/65-mermaid-diagram-atlas-sections-and-markdown-marker-replacement-helper.md@.
module Keiki.Render.Markdown
  ( MarkdownDiagramBlock (..),
    MarkdownDiagramError (..),
    replaceMarkdownDiagramBlock,
    beginMarker,
    endMarker,
  )
where

import Data.Text (Text)
import Data.Text qualified as T

-- | A diagram block to splice into a document. 'blockContent' is the
-- already-rendered block body (no fences); 'replaceMarkdownDiagramBlock'
-- wraps it in a normalized fenced block tagged with 'blockLanguage'.
data MarkdownDiagramBlock = MarkdownDiagramBlock
  { -- | Marker namespace, e.g. a service name.
    blockNamespace :: Text,
    -- | Marker id; the atlas @sectionId@.
    blockId :: Text,
    -- | Fenced-block language tag, e.g. @"mermaid"@.
    blockLanguage :: Text,
    -- | Already-rendered block body (no fences).
    blockContent :: Text
  }
  deriving stock (Eq, Show)

-- | Why a replacement could not be performed. Each carries enough text
-- to print the expected marker.
data MarkdownDiagramError
  = -- | The expected begin marker text, not found in the document.
    MissingBeginMarker Text
  | -- | The expected end marker text, not found in the document.
    MissingEndMarker Text
  | -- | A marker text found more than once, and the count found.
    DuplicateMarker Text Int
  deriving stock (Eq, Show)

-- | The begin marker for a @(namespace, id)@ pair.
beginMarker :: Text -> Text -> Text
beginMarker ns i =
  T.pack "<!-- " <> ns <> T.pack ": " <> i <> T.pack " begin -->"

-- | The end marker for a @(namespace, id)@ pair.
endMarker :: Text -> Text -> Text
endMarker ns i =
  T.pack "<!-- " <> ns <> T.pack ": " <> i <> T.pack " end -->"

-- | Replace everything between the begin and end markers for
-- @(blockNamespace, blockId)@ with a normalized fenced block. Preserves
-- the markers and every byte outside them.
--
-- The normalized block is
-- @```{blockLanguage}\\n{content}\\n```@ where @content@ is
-- 'blockContent' with trailing newlines stripped, placed on its own lines
-- between the (preserved) begin and end markers. Because the trailing
-- newlines are stripped before the closing fence, the helper is
-- __idempotent__: re-applying it to a document it already produced yields
-- the identical document.
--
-- Validation: exactly one begin and one end marker must be present.
-- A missing begin marker yields @'MissingBeginMarker' begin@; a missing
-- end yields @'MissingEndMarker' end@; a repeated marker yields
-- @'DuplicateMarker' marker count@.
replaceMarkdownDiagramBlock ::
  MarkdownDiagramBlock -> Text -> Either MarkdownDiagramError Text
replaceMarkdownDiagramBlock blk doc =
  let b = beginMarker (blockNamespace blk) (blockId blk)
      e = endMarker (blockNamespace blk) (blockId blk)
      nb = T.count b doc
      ne = T.count e doc
   in if
        | nb == 0 -> Left (MissingBeginMarker b)
        | nb > 1 -> Left (DuplicateMarker b nb)
        | ne == 0 -> Left (MissingEndMarker e)
        | ne > 1 -> Left (DuplicateMarker e ne)
        | otherwise ->
            let (pre, restB) = T.breakOn b doc
                afterBegin = T.drop (T.length b) restB
                (_, post) = T.breakOn e afterBegin
                fenced =
                  T.pack "```"
                    <> blockLanguage blk
                    <> T.pack "\n"
                    <> stripTrailingNewlines (blockContent blk)
                    <> T.pack "\n```"
             in Right (pre <> b <> T.pack "\n" <> fenced <> T.pack "\n" <> post)

-- | Drop any run of trailing @'\\n'@ characters.
stripTrailingNewlines :: Text -> Text
stripTrailingNewlines = T.dropWhileEnd (== '\n')