nbparts-0.1.0.0: src/Nbparts/Util/Markdown.hs
module Nbparts.Util.Markdown where
import Commonmark qualified
import Control.Monad.Identity (runIdentity)
import Data.Data (Data)
import Data.Data qualified as Data
import Data.Function ((&))
import Data.List.NonEmpty qualified as NonEmptyList
import Data.Maybe qualified as Maybe
import Data.Sequence (Seq ((:|>)))
import Data.Sequence qualified as Sequence
import Data.Text (Text)
import Data.Text qualified as Text
import Nbparts.Types.Sources.Markdown
( Block (Block),
BlockType (RawBlock, ReferenceLinkDefinition),
Blocks,
Inline (Inline),
InlineType (Image, RawInline),
Inlines (Inlines),
)
import Nbparts.Util.Text qualified as TextUtil
-- TODO: Revisit syntax spec
parseMarkdown :: Text -> Either Commonmark.ParseError Blocks
parseMarkdown mdText =
runIdentity $
mdText
& Commonmark.tokenize ""
& Commonmark.parseCommonmarkWith Commonmark.defaultSyntaxSpec
sourceRangeToIndices :: [Text] -> Commonmark.SourceRange -> Maybe (Int, Int)
sourceRangeToIndices mdLines (Commonmark.SourceRange srcRange) = do
let srcRange' = NonEmptyList.fromList srcRange
(startPos, _) = NonEmptyList.head srcRange'
(_, endPos) = NonEmptyList.last srcRange'
startIdx <- sourcePosToIndices mdLines startPos
endIdx <- sourcePosToIndices mdLines endPos
if startIdx <= endIdx
then
Just (startIdx, endIdx)
else
Nothing
sourcePosToIndices :: [Text] -> Commonmark.SourcePos -> Maybe Int
sourcePosToIndices mdLines srcPos = do
let line = Commonmark.sourceLine srcPos
column = Commonmark.sourceColumn srcPos
TextUtil.lineColToIndex mdLines line column
blockSourceRangeToIndices :: [Text] -> Commonmark.SourceRange -> Maybe (Int, Int)
blockSourceRangeToIndices mdLines (Commonmark.SourceRange srcRange) = do
let srcRange' = NonEmptyList.fromList srcRange
(startPos, _) = NonEmptyList.head srcRange'
(_, endPos) = NonEmptyList.last srcRange'
startIdx <- sourcePosToIndices mdLines startPos
endIdx <- blockEndSourcePosToIndices mdLines endPos
if startIdx <= endIdx
then
Just (startIdx, endIdx)
else
Nothing
blockEndSourcePosToIndices :: [Text] -> Commonmark.SourcePos -> Maybe Int
blockEndSourcePosToIndices mdLines srcPos = do
let lineCount = length mdLines
-- For the last block element, Commonmark seems to set the end index
-- to (number of lines + 1, 1), which is a problematic as there is
-- no `number of lines +1`th line.
let rawLine = Commonmark.sourceLine srcPos
rawColumn = Commonmark.sourceColumn srcPos
(line, column) =
if rawLine == lineCount + 1 && rawColumn == 1
then
(lineCount, Text.length (last mdLines) + 1)
else
(rawLine, rawColumn)
TextUtil.lineColToIndex mdLines line column
commentChangesWith :: (Text -> Text) -> [Text] -> Blocks -> [((Int, Int), Text)]
commentChangesWith transformComment mdLines = go
where
mdText = Text.intercalate "\n" mdLines
go :: (Data b) => b -> [((Int, Int), Text)]
go node
| Just (srcRange, html) <- htmlSourceRangeAndText node,
Text.isPrefixOf "<!--" html =
let -- Inlines don't have the same quirk as blocks (the (number of lines + 1, 1) end index issue).
-- Applying `blockSourceRangeToIndices` to the source range for inlines should exhibit the same
-- behaviour as `sourceRangeToIndices`.
-- Safety: The Commonmark parser and `blockSourceRangeToIndices` should always return valid indices.
indices@(startIdx, endIdx) = Maybe.fromJust $ blockSourceRangeToIndices mdLines srcRange
-- Unfortunately, the `html` given by the Commonmark parser leaves out some whitespace for
-- mutiline inline HTML, so we need to manually extract the full HTML from the original text.
fullHtml = mdText & Text.take endIdx & Text.drop startIdx
in [(indices, transformComment fullHtml)]
| otherwise = concat $ Data.gmapQ go node
htmlSourceRangeAndText :: (Data a) => a -> Maybe (Commonmark.SourceRange, Text)
htmlSourceRangeAndText node
| Just (Block (RawBlock (Commonmark.Format "html") html) srcRange _attrs) <- Data.cast node =
Just (srcRange, html)
| Just (Inline (RawInline (Commonmark.Format "html") html) srcRange _attrs) <- Data.cast node =
Just (srcRange, html)
| otherwise = Nothing
-- Because we want to maintain as much of the original formatting as possible,
-- instead of modifying the AST and turning it into text (which would modify some of the formatting
-- since the AST doesn't have enough information about the formatting), we collect the positions of
-- the image links and what to replace them with before performing the replacement using plain text
-- manipulation. That way, we leave everything else untouched.
attachmentChangesWith :: (Text -> Maybe Text) -> [Text] -> Blocks -> [((Int, Int), Text)]
attachmentChangesWith transformTarget mdLines = go
where
mdText = Text.intercalate "\n" mdLines
go :: (Data b) => b -> [((Int, Int), Text)]
go node
| Just (Inline (Image target _title (Inlines ils)) srcRange _attrs) <- Data.cast node =
let unSrcRange = NonEmptyList.fromList $ Commonmark.unSourceRange srcRange
-- Find the end index of the image's inlines, or if there are no inlines,
-- then just use the image's start index. This index is used as the starting
-- point from which to search for the target text.
-- Safety: This should never fail since the indices are valid.
searchStart = Maybe.fromJust $ sourcePosToIndices mdLines $ case ils of
_ :|> (Inline _ (Commonmark.SourceRange sr) _) -> snd $ last sr
Sequence.Empty -> fst $ NonEmptyList.head unSrcRange
in Maybe.maybeToList $ tryMkChange srcRange target searchStart
| Just (Block (ReferenceLinkDefinition label (target, _title)) srcRange _attrs) <- Data.cast node =
let unSrcRange = NonEmptyList.fromList $ Commonmark.unSourceRange srcRange
-- Safety: This should never fail since the indices are valid.
-- We're simply adding the length of the label to the start index of the reference link definition.
-- The length of the label is added to "skip" the label.
searchStart = Text.length label + Maybe.fromJust (sourcePosToIndices mdLines (fst $ NonEmptyList.head unSrcRange))
in Maybe.maybeToList $ tryMkChange srcRange target searchStart
| otherwise = concat $ Data.gmapQ go node
-- Finds the start and end indices of an image target and constructs the replacement.
--
-- If `transformTarget` fails, then we know this is not an attachment target.
-- `findSliceBetween` should never fail for a reference link definition. However,
-- it can fail for an image, in which case the target must be defined in a reference link definition.
-- In other words, the `Nothing` case of `tryMkChange` doesn't indicate failure, but simply means that
-- there is nothing we need to replace.
tryMkChange :: Commonmark.SourceRange -> Text -> Int -> Maybe ((Int, Int), Text)
tryMkChange srcRange target searchStart = do
transformedTarget <- transformTarget target
-- Now, we need to find the start and end indices of the image target.
let unSrcRange = NonEmptyList.fromList $ Commonmark.unSourceRange srcRange
-- Safety: This should never fail since the indices are valid.
searchEnd = Maybe.fromJust . sourcePosToIndices mdLines . snd . NonEmptyList.last $ unSrcRange
indices <- TextUtil.findSliceBetween searchStart searchEnd mdText target
Just (indices, transformedTarget)