commands.ps1

function ConvertFrom-MdBlock
{
<#
    .SYNOPSIS
        Converts special blocks defined in markdown into html.
     
    .DESCRIPTION
        Converts special blocks defined in markdown into html.
        The resultant html is appended to the stringbuilder specified.
        The conversion logic is provided by Register-EBMarkdownBlock.
        Returns whether the next line should be a first paragraph or a regular paragraph.
     
    .PARAMETER Type
        What kind of block is this?
     
    .PARAMETER Lines
        The lines of text contained in the block.
     
    .PARAMETER Attributes
        Any attributes provided to the block.
     
    .PARAMETER StringBuilder
        The stringbuilder containing the overall html string being built.
     
    .EXAMPLE
        PS C:\> ConvertFrom-MdBlock -Type $type -Lines $lines -Attributes @{ } -StringBuilder $builder
     
        Converts the provided block data to html and appends it to the stringbuilder.
        Returns whether the next line should be a first paragraph or a regular paragraph.
#>

    [CmdletBinding()]
    param (
        [parameter(Mandatory = $true)]
        [string]
        $Type,
        
        [parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]]
        $Lines,
        
        [parameter(Mandatory = $true)]
        [System.Collections.Hashtable]
        $Attributes,
        
        [parameter(Mandatory = $true)]
        [System.Text.StringBuilder]
        $StringBuilder
    )
    
    process
    {
        $converter = $script:mdBlockTypes[$Type]
        if (-not $converter)
        {
            Stop-PSFFunction -Message "Converter for block $Type not found! Make sure it is properly registered using Register-EBMarkdownBlock" -EnableException $true -Cmdlet $PSCmdlet -Category InvalidArgument
        }
        
        $data = [pscustomobject]($PSBoundParameters | ConvertTo-PSFHashtable)
        $converter.Invoke($data) -as [bool]
    }
}

function ConvertTo-MarkdownLine {
<#
    .SYNOPSIS
        Converts an input html paragraph to a markdown line of text.
     
    .DESCRIPTION
        Converts an input html paragraph to a markdown line of text.
     
    .PARAMETER Line
        The line of text to convert.
     
    .EXAMPLE
        PS C:\> ConvertTo-MarkdownLine -Line $Line
     
        Converts the HTML $Line to markdown
#>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
        [AllowEmptyString()]
        [string[]]
        $Line
    )
    
    begin {
        $mapping = @{
            '</{0,1}em>'     = '_'
            '</{0,1}i>'         = '_'
            '</{0,1}strong>' = '**'
            '</{0,1}b>'         = '**'
            '<br>'             = '<br />'
            '<span style="font-weight: 400">(.+?)</span>' = '$1'
        }
    }
    process {
        foreach ($string in $Line -replace ' </i>', '</i> ' -replace ' </em>', '</em> ') {
            foreach ($pair in $mapping.GetEnumerator()) {
                $string = $string -replace $pair.Key, $pair.Value
            }
            ($string -replace '</{0,1}p.{0,}?>').Trim()
        }
    }
}

function Read-RRChapter {
    <#
    .SYNOPSIS
        Reads a Royal Road chapter and breaks it down into its components.
     
    .DESCRIPTION
        Reads a Royal Road chapter and breaks it down into its components.
        Part of the parsing process to convert Royal Road books into eBooks.
     
    .PARAMETER Url
        Url to the specific RR page to process.
     
    .PARAMETER Index
        The chapter index to include in the return object
 
    .PARAMETER NoHeader
        The book does not include a header in the text portion.
        Will take the chapter-name as header instead.
 
    .PARAMETER Replacements
        A hashtable with replacements.
        At the root level, either use the "Global" index for replacements that apply to all chapters or the number of the chapter it applies to.
        Each value of those key/value pairs contains yet another hashtable, using a label as key (they label is ignored, use this for human documentation in the file) and yet another hashtable as value.
        That hashtable may contain three keys:
        - Pattern (mandatory)
        - Text (mandatory)
        - Weight (optional)
        The Pattern is a piece of text used to find matching text within the current chapter. Uses Regex.
        The Text is what we replace matched content with.
        The Weight - if specified - is the processing order in case of multiple replacements - the lower the number, the earlier is it processed.
     
    .EXAMPLE
        PS C:\> Read-RRChapter -Url https://www.royalroad.com/fiction/12345/evil-incarnate/chapter/666666/1-end-of-all-days
     
        Reads and converts the first chapter of evil incarnate (hint: does not exist)
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Url,
        
        [int]
        $Index,

        [switch]
        $NoHeader,

        [hashtable]
        $Replacements
    )
    
    begin {
        #region functions
        function Get-NextLink {
            [OutputType([string])]
            [CmdletBinding()]
            param (
                [parameter(ValueFromPipeline = $true)]
                [string]
                $Line
            )
            process {
                if ($Line -notlike '*<a class="btn btn-primary*>Next <br class="visible-xs" />Chapter</a>*') { return }
                $Line -replace '^.+href="(.+?)".+$', 'https://www.royalroad.com$1'
            }
        }

        function Get-Title {
            [OutputType([string])]
            [CmdletBinding()]
            param (
                [parameter(ValueFromPipeline = $true)]
                [string]
                $Line
            )
            process {
                if ($Line -notmatch '<h1 .+?>(.+?)</h1>') { return }
                $matches[1]
            }
        }
        
        function ConvertTo-Markdown {
            [OutputType([string])]
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                [string]
                $Line,

                [switch]
                $NoHeader,

                [string]
                $Title
            )
            
            begin {
                $firstLineCompleted = $false
                
                $badQuotes = @(
                    [char]8220
                    [char]8221
                    [char]8222
                    [char]8223
                )
                $badQuotesPattern = $badQuotes -join "|"
                $badSingleQuotes = @(
                    [char]8216
                    [char]8217
                    [char]8218
                    [char]8219
                )
                $badSingleQuotesPattern = $badSingleQuotes -join "|"

                if ($NoHeader) {
                    '# {0}' -f $Title
                    ''
                }
            }
            process {
                $lineNormalized = ($Line -replace $badQuotesPattern, '"' -replace $badSingleQuotesPattern, "'").Trim()
                if (-not $firstLineCompleted -and -not $NoHeader) {
                    '# {0}' -f ($lineNormalized -replace '</{0,1}p.{0,}?>' -replace '</{0,1}b>' -replace '</{0,1}strong>' -replace '<br>', '<br />')
                    ''
                    $firstLineCompleted = $true
                    return
                }
                
                if ($lineNormalized -eq '<p style="text-align: center">* * *</p>') {
                    @'
## <divide>
  * * *
## </divide>
 
'@

                    return
                }
                
                $lineNormalized | ConvertTo-MarkdownLine
                ''
            }
        }

        function ConvertTo-MarkdownFinal {
            [OutputType([string])]
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                [string]
                $Text,

                [Hashtable]
                $Replacements,

                [int]
                $ChapterIndex
            )
            begin {
                $mapping = @($Replacements.Global.Values) + @($Replacements[$ChapterIndex].Values) | Sort-Object Weight
            }
            process {
                foreach ($item in $mapping) {
                    $Text = $Text -replace $item.Pattern, $item.Text
                }
                $Text
            }
        }
        #endregion functions
    }
    process {
        $found = $false
        try { $allLines = (Invoke-WebRequest -Uri $Url -UseBasicParsing -ErrorAction Stop).Content -split "`n" }
        catch {
            if ($_.ErrorDetails.Message -ne 'Slow down!') { throw }
            Start-Sleep -Seconds 1
            $allLines = (Invoke-WebRequest -Uri $Url -UseBasicParsing -ErrorAction Stop).Content -split "`n"
        }
        $lines = $allLines | Where-Object {
            if ($_ -like '*<div class="chapter-inner chapter-content">*') {
                $found = $true
            }
            if ($_ -like '*<h6 class="bold uppercase text-center">Advertisement</h6>*') {
                $found = $false
            }
            
            # Remove all pictures, they don't close the tags correctly
            if (
                $_ -like '*<img*' -or
                $_ -like '*<input*'
            ) { return }
            $found
        }
        $title = $allLines | Get-Title
        [pscustomobject]@{
            Index    = $Index
            Title    = $title
            RawText  = $allLines -join "`n"
            Text     = $lines -join "`n" -replace '<br>', '<br />' -replace '<div class="chapter-inner chapter-content">', '<div>'
            TextMD   = $lines[1 .. ($lines.Length - 2)] | ConvertTo-Markdown -NoHeader:$NoHeader -Title $Title | Join-String "`n" | ConvertTo-MarkdownFinal -Replacements $Replacements -ChapterIndex $Index
            NextLink = $allLines | Get-NextLink
        }
    }
}

function ConvertFrom-EBMarkdown {
<#
    .SYNOPSIS
        A limited convertion from markdown to html.
     
    .DESCRIPTION
        A limited convertion from markdown to html.
        This command will process multiple lines into useful html.
        It is however limited in scope:
     
        + Paragraphs
        + Italic/emphasized text
        + Bold text
        + Bullet Points
     
        Other elements, such as comments (">") or headers ("#") are being ignored.
     
        This is due to this command being scoped not to converting whole pages, but instead for fairly small passages of markdown.
        Especially as a tool used within Blocks.
     
    .PARAMETER Line
        The lines of markdown string to convert.
     
    .PARAMETER EmphasisClass
        Which class to use for emphasized pieces of text.
        This is particularly intended for emphasis in text that is in italics by default.
     
        By default, emphasized text is wrapped into "<i>" and "</i>".
        However, when offerign a class instead a span tag is used:
        '<span class="EmphasisClass">' and '</span>'.
     
    .PARAMETER ClassFirstParagraph
        Which class to use for the first paragraph found.
        This affects the very first paragraph as well as any first paragraph after bulletpoints.
        Defaults to the same class as used for the ClassParagraph parameter.
     
    .PARAMETER ClassParagraph
        Which class to use for all paragraph but the first one.
        Defaults to: No class at all.
     
    .PARAMETER Classes
        A hashtable for mapping html tags to class names.
        Ignored for paragraphs, italic and bold, but can be used for example to add a class to "<li>" items.
     
    .PARAMETER AlwaysBreak
        By default, common markdown practice is to build a paragraph from multiple lines of text.
        Only on an empty line would a new paragraph be created.
        This can be disabled with this switch, causing every end of line to be treated as the end of a paragraph.
     
    .EXAMPLE
        PS C:\> ConvertFrom-EBMarkdown -Line $Data.Lines
     
        Converts all the lines of text in $Data.Lines without assigning special classes to any text.
     
    .EXAMPLE
        PS C:\> ConvertFrom-EBMarkdown -Line $Data.Lines -ClassParagraph blockOther -ClassFirstParagraph blockFirst -EmphasisClass blockEmphasis
     
        Converts all the lines of text in $Data.Lines, assigning the specified classes as applicable.
#>

    [OutputType([string])]
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]]
        $Line,
        
        [string]
        $EmphasisClass,
        
        [string]
        $ClassFirstParagraph,
        
        [string]
        $ClassParagraph,
        
        [hashtable]
        $Classes = @{ },
        
        [switch]
        $AlwaysBreak
    )
    
    begin {
        #region Utility Functions
        function Get-ClassString {
            [OutputType([string])]
            [CmdletBinding()]
            param (
                [string]
                $Name,
                
                [hashtable]
                $Classes
            )
            
            if (-not $Classes.$Name) { return '' }
            
            ' class="{0}"' -f $Classes.$Name
        }
        
        function Write-Paragraph {
            [OutputType([string])]
            [CmdletBinding()]
            param (
                [string[]]
                $Text,
                
                [string]
                $FirstParagraph = $ClassFirstParagraph,
                
                [string]
                $Paragraph = $ClassParagraph,
                
                [bool]
                $First = $isFirstParagraph
            )
            
            Set-Variable -Name isFirstParagraph -Scope 1 -Value $false
            Set-Variable -Name currentParagraph -Scope 1 -Value @()
            
            $class = $Paragraph
            if ($First -and $FirstParagraph) { $class = $FirstParagraph }
            $classString = Get-ClassString -Name p -Classes @{ p = $class }
            
            "<p$($classString)>$($Text -join " ")</p>"
        }
        #endregion Utility Functions
        
        $currentParagraph = @()
        $inBullet = $false
        $isFirstParagraph = $true
        
        $convertParam = $PSBoundParameters | ConvertTo-PSFHashtable -Include EmphasisClass
    }
    process {
        foreach ($string in $Line) {
            #region Empty Line
            if (-not $string) {
                if ($currentParagraph) { Write-Paragraph -Text $currentParagraph }
                if ($AlwaysBreak -and -not $inBullet) { Write-Paragraph -Text '&nbsp;' }
                if ($inBullet) {
                    '</ul>'
                    $inBullet = $false
                }
                continue
            }
            #endregion Empty Line
            
            #region Bullet Lists
            if ($string -match '^- |^\+ ') {
                $isFirstParagraph = $true
                if (-not $inBullet) {
                    if ($currentParagraph) { Write-Paragraph -Text $currentParagraph }
                    "<ul$(Get-ClassString -Name ul -Classes $Classes)>"
                    $inBullet = $true
                }
                "<li$(Get-ClassString -Name li -Classes $Classes)>$($string | Set-String -OldValue '^- |^\+ ' | ConvertFrom-EBMarkdownLine @convertParam)</li>"
                continue
            }
            #endregion Bullet Lists
            
            #region Default: paragraph
            $currentParagraph += $string | ConvertFrom-EBMarkdownLine @convertParam
            
            if ($AlwaysBreak) { Write-Paragraph -Text $currentParagraph }
            #endregion Default: paragraph
        }
    }
    end {
        if ($inBullet) {
            '</ul>'
        }
        if ($currentParagraph) { Write-Paragraph -Text $currentParagraph }
    }
}

function ConvertFrom-EBMarkdownLine
{
<#
    .SYNOPSIS
        Converts markdown notation of bold and cursive to html.
     
    .DESCRIPTION
        Converts markdown notation of bold and cursive to html.
     
    .PARAMETER Line
        The line of text to convert.
     
    .PARAMETER EmphasisClass
        The tag to wrap text in that was marked in markdown with "_" symbols
        By default it encloses with italic tags ("<i>Test</i>"), specifying a class will change it to a span instead.
     
    .EXAMPLE
        PS C:\> ConvertFrom-EBMarkdownLine -Line '_value1_'
         
        Will convert "_value1_" to "<i>value1</i>"
#>

    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true, Mandatory = $true)]
        [string[]]
        $Line,
        
        [string]
        $EmphasisClass
    )
    
    begin {
        $emphasis = '<i>$1</i>'
        if ($EmphasisClass) {
            $emphasis = '<span class="{0}">$1</span>' -f $EmphasisClass
        }
    }
    process
    {
        foreach ($string in $Line) {
            $string -replace '\*\*(.+?)\*\*', '<b>$1</b>' -replace '_(.+?)_', $emphasis
        }
    }
}

function ConvertTo-EBHtmlInlineStyle {
<#
    .SYNOPSIS
        Converts html documents from using classes to inline style attributes.
     
    .DESCRIPTION
        Converts html documents from using classes to inline style attributes.
        Needed in situations where the target system doesn't support use of stylesheets.
     
    .PARAMETER Text
        The text to convert.
     
    .PARAMETER CssData
        CSS text to use for resolving classes to style attributes.
     
    .PARAMETER Style
        CSS style objects as resolved by Read-EBCssStyleSheet
        Note: The command should be used with the "-Merge" option to include the defautl settings for its html tag.
     
    .EXAMPLE
        PS C:\> ConvertTo-EBHtmlInlineStyle -Text $htmlContent -Style $styleObjects
     
        Convert the text in $htmlContent using the styles in $styleObjects.
        This will replace all style attributes with fitting style attributes.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [AllowEmptyString()]
        [string[]]
        $Text,
        
        [Parameter(Mandatory = $true, ParameterSetName = 'Text')]
        [string]
        $CssData,
        
        [Parameter(Mandatory = $true, ParameterSetName = 'Object')]
        [EbookBuilder.StyleObject[]]
        $Style
    )
    
    begin {
        $styleObjects = $Style
        if ($CssData) { $styleObjects = Read-EBCssStyleSheet -CssData $CssData }
        
        $stylesRoot = $styleObjects | Where-Object Class -EQ ''
        $stylesClass = $styleObjects | Where-Object Class
    }
    process {
        foreach ($textItem in $Text) {
            foreach ($styleItem in $stylesClass) {
                $textItem = $textItem -replace "<$($styleItem.Tag) ([^>]{0,})class=`"$($styleItem.Class)`"([^>]{0,})>", "<$($styleItem.Tag) `$1$($styleItem.ToInline($true))`$2>"
            }
            foreach ($styleItem in $stylesRoot) {
                $textItem = $textItem -replace "<$($styleItem.Tag)>", "<$($styleItem.Tag) $($styleItem.ToInline($true))>"
            }
            $textItem
        }
    }
}

function Export-EBBook
{
<#
    .SYNOPSIS
        Exports pages and images into a epub ebook.
     
    .DESCRIPTION
        Exports pages and images into a epub ebook.
     
    .PARAMETER Path
        The path to export to.
        Will ignore the name if an explicit filename was specified.
     
    .PARAMETER Name
        The name of the ebook. Will also be used for the filename if a path to a folder was specified.
        Defaults to: New Book
     
    .PARAMETER FileName
        Explicitly specify the name of the exported file.
        The "Name" parameter will be used to calculate it if not specified.
     
    .PARAMETER Author
        The author to set for the ebook.
     
    .PARAMETER Publisher
        The publisher of the ebook.
     
    .PARAMETER CssData
        Custom CSS to use to style the ebook.
        Allows you to tune how the ebook is styled.
     
    .PARAMETER Page
        The pages to compile into an ebook.
     
    .PARAMETER Series
        The name of the series this book is part of.
        Added as metadata to the build ebook.
     
    .PARAMETER Volume
        The volume number of the series this book is part of.
        Only effecive if used together with the Series parameter.
     
    .PARAMETER Tags
        Any tags to add to the book's metadata.
     
    .PARAMETER Description
        A description to include in the book's metadata.
     
    .EXAMPLE
        PS C:\> Read-EBMicrosoftDocsIndexPage -Url https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/best-practices-for-securing-active-directory | Export-EBBook -Path . -Name ads-best-practices.epub -Author "Friedrich Weinmann" -Publisher "Infernal Press"
         
        Compiles an ebook out of the Active Directory Best Practices.
#>

    [CmdletBinding()]
    param (
        [PsfValidateScript('PSFramework.Validate.FSPath.FileOrParent', ErrorString = 'PSFramework.Validate.FSPath.FileOrParent')]
        [string]
        $Path = ".",
        
        [string]
        $Name = "New Book",
        
        [string]
        $FileName,
        
        [string]
        $Author = $env:USERNAME,
        
        [string]
        $Publisher = $env:USERNAME,
        
        [string]
        $CssData,
        
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [EbookBuilder.Item[]]
        $Page,
        
        [string]
        $Series,
        
        [int]
        $Volume,
        
        [string[]]
        $Tags,
        
        [string]
        $Description
    )
    
    begin
    {
        #region Functions
        function Write-File
        {
            [CmdletBinding()]
            param (
                [System.IO.DirectoryInfo]
                $Root,
                
                [string]
                $Path,
                
                [string]
                $Text
            )
            
            $tempPath = Resolve-PSFPath -Path (Join-Path $Root.FullName $Path) -NewChild
            Write-PSFMessage -Level SomewhatVerbose -Message "Writing file: $($Path)"
            $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
            [System.IO.File]::WriteAllText($tempPath, $Text, $utf8NoBom)
        }
        
        function ConvertTo-ManifestPageData
        {
            [CmdletBinding()]
            param (
                $Pages
            )
            
            $lines = $Pages | ForEach-Object {
                    ' <item id="{0}" href="Text/{0}" media-type="application/xhtml+xml"/>' -f $_.EbookFileName
            }
            $lines -join "`n"
        }
        
        function ConvertTo-ManifestImageData
        {
            [CmdletBinding()]
            param (
                $Images
            )
            
            $lines = $images | ForEach-Object {
                ' <item id="{0}" href="Images/{1}" media-type="image/{2}"/>' -f ($_.ImageID -replace "\s","_"), $_.FileName, "Jpeg"
            }
            $lines -join "`n"
        }
        #endregion Functions
        
        #region Prepare Resources
        if (-not $FileName) { $FileName = $Name }
        $resolvedPath = Resolve-PSFPath -Path $Path -Provider FileSystem -SingleItem -NewChild
        if (Test-Path $resolvedPath)
        {
            if ((Get-Item $resolvedPath).PSIsContainer) { $resolvedPath = Join-Path $resolvedPath $FileName }
        }
        if ($resolvedPath -notlike "*.epub") { $resolvedPath += ".epub" }
        $zipPath = $resolvedPath -replace 'epub$', 'zip'
        $cssContent = $CssData
        if (-not $cssContent)
        {
            $cssContent = [System.IO.File]::ReadAllText((Resolve-Path "$($script:ModuleRoot)\data\Common.css"), [System.Text.Encoding]::UTF8)
        }
        $pages = @()
        $images = @()
        #endregion Prepare Resources
    }
    process
    {
        #region Process Input items
        foreach ($item in $Page)
        {
            switch ($item.Type)
            {
                "Page" { $pages += $item }
                "Image" { $images += $item }
            }
        }
        #endregion Process Input items
    }
    end
    {
        $id = 1
        $pages = $pages | Sort-Object Index | Select-PSFObject -KeepInputObject -Property @{
            Name = "EbookFileName"
            # Expression = { "{0}.xhtml" -f (New-Guid) }
            Expression = { "Chapter {0:D3}.xhtml" -f $_.Index }
        }, @{
            Name = "TocIndex"
            Expression = { $id++ }
        }
        
        $tempPath = New-Item -Path $env:TEMP -Name "Ebook-$(Get-Random -Maximum 99999 -Minimum 10000)" -ItemType Directory -Force
        
        Write-File -Root $tempPath -Path 'mimetype' -Text 'application/epub+zip'
        $metaPath = New-Item -Path $tempPath.FullName -Name "META-INF" -ItemType Directory
        Write-File -Root $metaPath -Path 'container.xml' -Text @'
<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
    <rootfiles>
        <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
   </rootfiles>
</container>
'@

        $oebpsPath = New-Item -Path $tempPath.FullName -Name "OEBPS" -ItemType Directory
        
        #region content.opf
        $contentOpfText = @"
<?xml version="1.0" encoding="utf-8"?>
<package version="2.0" unique-identifier="uuid_id" xmlns="http://www.idpf.org/2007/opf">
  <metadata xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:opf="http://www.idpf.org/2007/opf" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:calibre="http://calibre.kovidgoyal.net/2009/metadata">
    <dc:publisher>$Publisher</dc:publisher>
    <dc:language>en</dc:language>
    <dc:creator opf:role="aut" opf:file-as="$Author">$Author</dc:creator>
    <dc:title opf:file-as="$Name">$Name</dc:title>
"@

        if ($Description) { $contentOpfText += "`n <dc:description>$Description</dc:description>" }
        if ($Series) {
            $contentOpfText += @"
 
    <opf:meta content="$Series" name="calibre:series" />
    <opf:meta content="$Volume.0" name="calibre:series_index" />
"@

        }
        foreach ($tag in $Tags) {
            $contentOpfText += "`n <dc:subject>$tag</dc:subject>"
        }
        $contentOpfText += @"
 
  </metadata>
  <manifest>
$(ConvertTo-ManifestPageData -Pages $pages)
$(ConvertTo-ManifestImageData -Images $images)
    <item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
    <item id="style.css" href="Styles/Style.css" media-type="text/css"/>
  </manifest>
  <spine toc="ncx">
$($pages | Format-String -Format ' <itemref idref="{0}"/>' -Property EbookFileName | Join-String "`n")
  </spine>
  <guide/>
</package>
"@

        Write-File -Root $oebpsPath -Path 'content.opf' -Text $contentOpfText
        #endregion content.opf
        
        #region TOC.ncx
        $bookMarkText = ($pages | ForEach-Object {
                $tocIndex = $_.Index
                if ($_.TocIndex) { $tocIndex = $_.TocIndex}
                @'
    <navPoint id="navPoint-{0}" playOrder="{0}">
      <navLabel>
        <text>Chapter {0}</text>
      </navLabel>
      <content src="Text/{1}"/>
    </navPoint>
'@
 -f $tocIndex, $_.EbookFileName
        }) -join "`n"
        
        $contentTocNcxText = @'
<?xml version="1.0" encoding="utf-8" ?>
<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN"
 "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd"><ncx version="2005-1" xmlns="http://www.daisy.org/z3986/2005/ncx/">
  <head>
    <meta content="{0}" name="dtb:uid"/>
    <meta content="1" name="dtb:depth"/>
    <meta content="0" name="dtb:totalPageCount"/>
    <meta content="0" name="dtb:maxPageNumber"/>
  </head>
  <docTitle>
    <text>{1}</text>
  </docTitle>
  <navMap>
{2}
  </navMap>
</ncx>
'@
 -f (New-Guid), $Name, $bookMarkText
        Write-File -Root $oebpsPath -Path 'toc.ncx' -Text $contentTocNcxText
        #endregion TOC.ncx
        
        #region Files
        $stylesPath = New-Item -Path $oebpsPath.FullName -Name "Styles" -ItemType Directory
        Write-File -Root $stylesPath -Path 'Style.css' -Text $cssContent
        
        $textPath = New-Item -Path $oebpsPath.FullName -Name 'Text' -ItemType Directory
        foreach ($pageItem in $pages)
        {
            $pageText = @'
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
  "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
 
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
  <title>{0}</title>
  <meta content="http://www.w3.org/1999/xhtml; charset=utf-8" http-equiv="Content-Type"/>
  <link href="../Styles/Style.css" type="text/css" rel="stylesheet"/>
</head>
<body>
{1}
</body>
</html>
'@
 -f $Name, $pageItem.Content
            Write-File -Root $textPath -Path $pageItem.EbookFileName -Text $pageText
        }
        #endregion Files
        
        #region Images
        if ($images)
        {
            $imagesPath = New-Item -Path $oebpsPath.FullName -Name 'Images' -ItemType Directory
            
            foreach ($image in $images)
            {
                $targetPath = Join-Path $imagesPath.FullName $image.FileName
                [System.IO.File]::WriteAllBytes($targetPath, $image.Data)
            }
        }
        #endregion Images
        
        Get-ChildItem $tempPath | Compress-Archive -DestinationPath $zipPath -Force
        if (Test-Path -Path $resolvedPath) {
            Remove-Item -Path $resolvedPath -Force -ErrorAction Ignore
        }
        Rename-Item -Path $zipPath -NewName (Split-Path $resolvedPath -Leaf)
        Remove-Item $tempPath -Recurse -Force
    }
}

function Export-EBMdBook {
<#
    .SYNOPSIS
        Converts a markdown-based book project into epub ebooks.
     
    .DESCRIPTION
        Converts a markdown-based book project into epub ebooks.
        This is the top-level execution command for processing the book pipeline.
     
        For details, see the description on New-EBBookProject.
     
    .PARAMETER ConfigFile
        The path to the configuration file, defining the properties of the book project.
     
    .EXAMPLE
        PS C:\> Export-EBMdBook -ConfigFile .\config.psd1
     
        Builds the book project in the current folder.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [PsfValidateScript('PSFramework.Validate.FSPath.File', ErrorString = 'PSFramework.Validate.FSPath.File')]
        [string]
        $ConfigFile
    )
    
    $baseFolder = Split-Path -Path (Resolve-PSFPath -Path $ConfigFile)
    $config = Import-PSFPowerShellDataFile -Path $ConfigFile
    $bookRoot = Join-Path -Path $baseFolder -ChildPath $config.OutPath
    $blockRoot = Join-Path -Path $baseFolder -ChildPath $config.Blocks
    $exportPath = Join-Path -Path $baseFolder -ChildPath $config.ExportPath
    $rrExportPath = ''
    if ($config.RRExportPath) { $rrExportPath = Join-Path -Path $baseFolder -ChildPath $config.RRExportPath }
    
    $author = "Unknown"
    if ($config.Author) { $author = $config.Author }
    $publisher = "Unknown"
    if ($config.Publisher) { $publisher = $config.Publisher }
    
    $cssPath = $null
    if ($config.Style) {
        $cssPath = Join-Path -Path $baseFolder -ChildPath $config.Style
    }
    $rrCssPath = $null
    if ($config.RRStyle) {
        $rrCssPath = Join-Path -Path $baseFolder -ChildPath $config.RRStyle
    }
    $inlineHash = @{ }
    if ($config.InlineConfig) {
        $inlineHash = Import-PSFPowerShellDataFile -Path (Join-Path -Path $baseFolder -ChildPath $config.InlineConfig)
    }
    
    foreach ($file in Get-ChildItem -Path $blockRoot -File -Filter *.ps1) {
        & {
            . $file.FullName
        }
    }
    
    if ($rrExportPath) {
        $rrExportParam = @{
            Path = $rrExportPath
            Name = $config.Name
        }
        if ($cssPath -and -not $rrCssPath) {
            $rrExportParam.CssData = Get-ChildItem -Path $cssPath -Filter *.css | ForEach-Object {
                Get-Content -Path $_.FullName
            } | Join-String -Separator "`n"
        }
        if ($rrCssPath) {
            $rrExportParam.CssData = Get-ChildItem -Path $rrCssPath -Filter *.css | ForEach-Object {
                Get-Content -Path $_.FullName
            } | Join-String -Separator "`n"
        }
        
        $rrExportPipe = { Export-EBRoyalRoadPage @rrExportParam }.GetSteppablePipeline()
        $rrExportPipe.Begin($true)
    }
    
    foreach ($folder in Get-ChildItem -Path $bookRoot -Directory) {
        $volume = ($folder.Name -split "-")[0] -as [int]
        $bookName = ($folder.Name -split "-", 2)[1].Trim()
        
        $exportParam = @{
            Name = $bookName
            FileName = '{0:D3}-{1}' -f $volume, $bookName
            Path = $exportPath
            Author = $author
            Publisher = $publisher
            Series = $config.Name
            Volume = $volume
        }
        if ($cssPath) {
            $exportParam.CssData = Get-ChildItem -Path $cssPath -Filter *.css | ForEach-Object {
                Get-Content -Path $_.FullName
            } | Join-String -Separator "`n"
        }
        if ($config.Tags) { $exportParam.Tags = $config.Tags }
        
        $exportPipe = { Export-EBBook @exportParam }.GetSteppablePipeline()
        $exportPipe.Begin($true)
        Get-ChildItem -Path $folder.FullName -File -Filter *.md | Read-EBMarkdown -InlineStyles $inlineHash | ForEach-Object {
            $exportPipe.Process($_)
            if ($rrExportPath) { $rrExportPipe.Process($_) }
        }
        $picturePath = Join-Path -Path $folder.FullName -ChildPath pictures
        if (Test-Path -Path $picturePath) {
            foreach ($file in Get-ChildItem -Path $picturePath -File | Where-Object Extension -in '.jpeg', '.png', '.jpg', '.bmp') {
                $pictureObject = [EbookBuilder.Picture]::GetPicture($file)
                $exportPipe.Process($pictureObject)
            }
        }
        $exportPipe.End()
    }
    if ($rrExportPath) { $rrExportPipe.End() }
}

function Export-EBRoyalRoadPage
{
<#
    .SYNOPSIS
        Export book pages as HTML document for publishing on Royal Road.
     
    .DESCRIPTION
        Export book pages as HTML document for publishing on Royal Road.
        This involves resolving all stylesheets and converting them to inline style-attributes.
     
    .PARAMETER Name
        Name of the series.
     
    .PARAMETER Path
        Path to the folder in which to create files.
        Creates one file per chapter.
        Folder must exist.
     
    .PARAMETER CssData
        CSS style content to use.
        If left empty, it will use the module defaults.
     
    .PARAMETER Page
        The page object to generate documents for.
        Must be the output of Read-DBMarkdown.
     
    .EXAMPLE
        PS C:\> Export-EBRoyalRoadPage -Name 'MyBookSeries' -Path '.' -Page $page
     
        Exports all pages to the current folder.
#>

    [CmdletBinding()]
    param (
        [parameter(Mandatory = $true)]
        [string]
        $Name,
        
        [PsfValidateScript('PSFramework.Validate.FSPath.Folder', ErrorString = 'PSFramework.Validate.FSPath.Folder')]
        [Parameter(Mandatory = $true)]
        [string]
        $Path,
        
        [string]
        $CssData,
        
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [EbookBuilder.Page[]]
        $Page
    )
    
    begin
    {
        $cssContent = $CssData
        if (-not $cssContent) {
            $cssContent = [System.IO.File]::ReadAllText((Resolve-Path "$($script:ModuleRoot)\data\Common.css"), [System.Text.Encoding]::UTF8)
        }
        $styles = Read-EBCssStyleSheet -CssData $cssContent -Merge
        $resolvedPath = Resolve-PSFPath -Path $Path
        $encoding = [System.Text.UTF8Encoding]::new()
        $index = 1
    }
    process
    {
        foreach ($pageObject in $Page) {
            $newFile = Join-Path -Path $resolvedPath -ChildPath ("{0}_{1:D5}.html" -f $Name, $index)
            $content = $pageObject.Content | ConvertTo-EBHtmlInlineStyle -Style $styles
            if (-not (Test-Path $newFile)) {
                Write-PSFMessage -Level Host -Message 'Creating new chapter: {0:D5} ({1})' -StringValues $index, $newFile
            }
            else {
                $currentContent = [System.IO.File]::ReadAllText($newFile, $encoding)
                if ($currentContent -ne $content) {
                    Write-PSFMessage -Level Host -Message 'Updating chapter: {0:D5} ({1})' -StringValues $index, $newFile
                }
            }
            [System.IO.File]::WriteAllText($newFile, $content, $encoding)
            
            $index++
        }
    }
}

function New-EBBookProject
{
<#
    .SYNOPSIS
        Create a new ebook project.
     
    .DESCRIPTION
        Create a new ebook project.
        This project will be designed for authoring in markdown.
        Recommended editor is VSCode, automation requires PowerShell and this module even after creation.
        All three can be installed on any common client Operating System, such as Windows, Linux or MacOS.
     
        It is recommended, but not required, to use a source control service such as GitHub to host your project (for free and not necessarily public).
     
    .PARAMETER Path
        The path where the project should be created.
        Defaults to the current path.
     
    .PARAMETER Name
        The name of the series / book.
        (This project template is designed with a series in mind, but can be used for a single book just as well)
     
    .PARAMETER Author
        The Author of the book.
     
    .PARAMETER Publisher
        The Publisher for this book.
     
    .EXAMPLE
        PS C:\> New-EBBookProject -Name 'Genesis'
     
        Creates a new book project named "Genesis" in the current path.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Name,
        
        [PsfValidateScript('PSFramework.Validate.FSPath.Folder', ErrorString = 'PSFramework.Validate.FSPath.Folder')]
        [string]
        $Path = '.',
        
        [string]
        $Author,
        
        [string]
        $Publisher
    )
    
    process
    {
        $parameters = @{
            TemplateName = 'BookProject'
            NoFolder     = $true
            OutPath         = $Path
            Parameters = $PSBoundParameters | ConvertTo-PSFHashtable -Include Name, Author, Publisher
        }
        Invoke-PSMDTemplate @parameters
        Write-PSFMessage -Level Host -Message "Book Project $Name created under $(Resolve-PSFPath $Path)"
    }
}

function Read-EBCssStyleSheet
{
<#
    .SYNOPSIS
        Parse a stylesheet and convert it into an object model.
     
    .DESCRIPTION
        Parse a stylesheet and convert it into an object model.
     
    .PARAMETER CssData
        CSS data provided as a string.
     
    .PARAMETER Path
        Path to CSS stylesheets to parse.
     
    .PARAMETER Merge
        For all styles bound to a class, merge in the settings of the base styles for the tag the class applies to.
     
    .EXAMPLE
        PS C:\> Read-EBCssStyleSheet -CssData $content
     
        Parses the CSS styles contained as string in $content
     
    .EXAMPLE
        PS C:\> Get-ChildItem *.css | Read-EBCssStyleSheet
     
        Parses all CSS stylesheets in the current folder.
#>

    [OutputType([EbookBuilder.StyleObject])]
    [CmdletBinding(DefaultParameterSetName = 'Text')]
    param (
        [Parameter(Mandatory = $true, Position = 0, ParameterSetName = 'Text')]
        [string]
        $CssData,
        
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, ParameterSetName = 'File')]
        [Alias('FullName')]
        [string[]]
        $Path,
        
        [switch]
        $Merge
    )
    
    begin{
        #region Functions
        function Convert-StyleSheet {
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                [string[]]
                $Text,
                
                [hashtable]
                $ResultHash
            )
            
            begin {
                $currentTag = ''
                $currentClass = '_default'
                $inComment = $false
            }
            process {
                foreach ($line in $Text | Get-SubString -Trim " " | Split-String "`n" | Get-SubString -Trim " " | Remove-PSFNull) {
                    Write-PSFMessage -Level InternalComment -Message ' {0}' -StringValues $line
                    #region Comments
                    if ($line -match "^/\*") { $inComment = $true }
                    if ($line -match "\*/") { $inComment = $false }
                    if ($inComment -or $line -match "^/\*" -or $line -match "\*/") { continue }
                    #endregion Comments
                    
                    #region Close CSS
                    if ($line -eq "}") {
                        $currentTag = ''
                        $currentClass = '_default'
                        
                        continue
                    }
                    #endregion Close CSS
                    
                    #region Open CSS
                    if ($line -like "*{") {
                        $tempLine = $line | Get-SubString -TrimEnd " {"
                        $segments = $tempLine -split "\.",2
                        $currentTag = $segments[0]
                        if ($segments[1]) { $currentClass = $segments[1] }
                        else { $currentClass = '_default' }
                        continue
                    }
                    #endregion Open CSS
                    
                    #region Content
                    if (-not $ResultHash[$currentTag]) { $ResultHash[$currentTag] = @{ } }
                    if (-not $ResultHash[$currentTag][$currentClass]) { $ResultHash[$currentTag][$currentClass] = @{ } }
                    
                    $key, $value = $line -split ":", 2
                    if (-not $key -or -not $value) { continue }
                    Write-PSFMessage -Level InternalComment -Message ' {0} : {1}' -StringValues $key, $value
                    $ResultHash[$currentTag][$currentClass][$key.Trim()] = $value.Trim(" ;")
                    #endregion Content
                }
            }
        }
        
        function Resolve-StyleHash {
            [OutputType([EbookBuilder.StyleObject])]
            [CmdletBinding()]
            param (
                [hashtable]
                $ResultHash,
                
                [bool]
                $Merge
            )
            
            foreach ($pair in $ResultHash.GetEnumerator()) {
                $tagName = $pair.Key
                
                $defaultStyle = @{ }
                if ($Merge -and $pair.Value._Default) {
                    $defaultStyle = $pair.Value._Default
                }
                
                foreach ($entry in $pair.Value.GetEnumerator()) {
                    $style = [EbookBuilder.StyleObject]::new()
                    $style.Tag = $tagName
                    if ($entry.Key -ne '_default') { $style.Class = $entry.Key }
                    
                    $hash = $defaultStyle.Clone()
                    foreach ($attribute in $entry.Value.GetEnumerator()) { $hash[$attribute.Key] = $attribute.Value }
                    foreach ($attribute in $hash.GetEnumerator()) { $style.Attributes[$attribute.Key] = $attribute.Value }
                    $style
                }
            }
        }
        #endregion Functions
        
        $resultHash = @{ }
    }
    process
    {
        if ($CssData) { $CssData | Convert-StyleSheet -ResultHash $resultHash }
        foreach ($filePath in $Path) {
            Write-PSFMessage -Message 'Loading style file: {0}' -StringValues $filePath
            Get-Content -Path $filePath | Convert-StyleSheet -ResultHash $resultHash
        }
    }
    end
    {
        Resolve-StyleHash -ResultHash $resultHash -Merge $Merge
    }
}

function Read-EBEpub {
<#
    .SYNOPSIS
        Extract chapters from an epub-formatted ebook and convert them to markdown.
     
    .DESCRIPTION
        Extract chapters from an epub-formatted ebook and convert them to markdown.
     
        Markdown parsing strongly relies on the provided replacements file.
     
    .PARAMETER Path
        The path to the ebook to parse.
     
    .PARAMETER OutPath
        The folder to which to export the chapters.
     
    .PARAMETER Name
        Name of the book being parsed.
        Used in the output files' name.
     
    .PARAMETER ReplacementPath
        Path to a PowerShell data file (*.psd1) containing replacements to use with the reading effort.
        The file should contain a single hashtable with a single key: Values.
        This shall then contain a list of replacement definitions using regex.
        Example content:
     
        @{
            Values = @(
                @{
                    What = '<p class="text">(.+?)</p>'
                    With = '$1'
                    Weight = 1
                }
            )
        }
     
        What: The pattern in the source file to match
        With: What to replace it with
        Weight: The processing order when specifying multiple replacements. Lower numbers go first.
     
    .PARAMETER StartIndex
        The number the first chapter starts with.
        Only affects the file name of the output.
        Defaults to:1
     
    .EXAMPLE
        PS C:\> Read-EBEpub -Path '.\pirates.epub' -OutPath 'C:\ebooks\pirates\chapters' -ReplacementPath 'C:\ebooks\pirates\replacements.psd1' -Name Pirates
     
        Reads the "Pirates.epub" file, extracts the chapters to the specified output path, using the replacements provided inreplacements.psd1.
#>

    
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('FullName')]
        [string[]]
        $Path,
        
        [Parameter(Mandatory = $true)]
        [string]
        $OutPath,
        
        [string]
        $Name = 'unknown',
        
        [string]
        $ReplacementPath,
        
        [int]
        $StartIndex = 1
    )
    
    begin{
        #region Functions
        function Convert-Chapter {
            [OutputType([string])]
            [CmdletBinding()]
            param (
                [string]
                $Content,
                
                $Replacements
            )
            $string = $Content -replace '</p>', "</p>`n" -replace '</{0,1}html[^>]{0,}>|</{0,1}body[^>]{0,}>|</{0,1}head[^>]{0,}>|</{0,1}link[^>]{0,}>|</{0,1}meta[^>]{0,}>|</{0,1}\?{0,1}xml[^>]{0,}>'
            $string = $string -split "\n" | ForEach-Object Trim | Remove-PSFNull | Join-String "`n`n"
            foreach ($item in $Replacements | Sort-Object Weight) {
                $string = $string -replace $item.What, $item.With
            }
            $string -replace '</{0,1}p[^>]{0,}>' -replace "(?s)\n{3,}", "`n`n"
        }
        #endregion Functions
        
        $tempFolder = Join-Path -Path (Get-PSFPath -Name Temp) -ChildPath "Ebook_Temp_$(Get-Random)"
        $null = New-Item -Path $tempFolder -ItemType Directory -Force
        $chapterIndex = $StartIndex
        $outputPath = Resolve-PSFPath -Path $OutPath -Provider FileSystem -SingleItem
        
        $replacements = @()
        if ($ReplacementPath) {
            $data = Import-PSFPowerShellDataFile -Path $ReplacementPath
            $replacements = foreach ($entry in $data.Values) {
                [PSCustomObject]$entry
            }
        }
        
    }
    process
    {
        foreach ($filePath in $Path) {
            foreach ($resolvedPath in Resolve-PSFPath -Path $filePath) {
                Expand-Archive -Path $resolvedPath -DestinationPath $tempFolder -Force
                foreach ($chapter in Get-ChildItem -Path "$tempFolder\OEBPS\sections") {
                    $content = [System.IO.File]::ReadAllText($chapter.FullName)
                    $newContent = Convert-Chapter -Content $content -Replacements $Replacements
                    $newFileName = '{0}-{1:D4}.md' -f $Name, $chapterIndex
                    [System.IO.File]::WriteAllText("$outputPath\$newFileName", $newContent)
                    $chapterIndex++
                }
                Remove-Item -Path "$tempFolder\*" -Force -Recurse
            }
        }
    }
    end
    {
        Remove-Item -Path $tempFolder -Force -Recurse -ErrorAction Ignore
    }
}

function Read-EBMarkdown {
    <#
    .SYNOPSIS
        Reads a markdown file and converts it to a page to be built into an ebook
     
    .DESCRIPTION
        Reads a markdown file and converts it to a page to be built into an ebook
     
    .PARAMETER Path
        Path to the file to read.
         
    .PARAMETER InlineStyles
        Hashtable mapping inline decorators to span classes.
        Used to enable inline style customizations.
        For example, when providing a hashtable like this:
        @{ 1 = 'spellcast' }
        It will convert this line:
        "Let me show you my #1#Fireball#1#!"
        into
        "Let me show you my <span class="spellcast">Fireball</span>!"
     
    .EXAMPLE
        PS C:\> Get-ChildItem *.md | Read-EBMarkdown
 
        Reads and converts all markdown files in he current folder
    #>

    [OutputType([EbookBuilder.Page])]
    [CmdletBinding()]
    param (
        [parameter(ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('FullName')]
        [string[]]
        $Path,
        
        [hashtable]
        $InlineStyles = @{ }
    )
    
    begin {
        function ConvertFrom-Markdown {
            [OutputType([EbookBuilder.Page])]
            [CmdletBinding()]
            param (
                [string]
                $Path,
                
                [int]
                $Index,
                
                [hashtable]
                $InlineStyles = @{ }
            )
            
            $lines = Get-Content -Path $Path -Encoding UTF8 | ConvertFrom-InlineStyle -InlineStyles $InlineStyles
            $stringBuilder = New-SBStringBuilder -Name ebook
            $PSDefaultParameterValues['Add-SBLine:Name'] = 'ebook'
            
            $inBlock = $false
            $inCode = $false
            $inBullet = $false
            $inNote = $false
            $inParagraph = $false
            
            $blockData = [pscustomobject]@{
                Attributes = @{ }
                Type       = $null
                Lines       = @()
                File       = $Path
            }
            $paragraph = @()
            $firstPar = $true
            
            foreach ($line in $lines) {
                #region Process Block Content
                if ($inBlock) {
                    if ($line -like '## <*') {
                        try { $firstPar = ConvertFrom-MdBlock -Type $blockData.Type -Lines $blockData.Lines -Attributes $blockData.Attributes -StringBuilder $stringBuilder }
                        catch { Stop-PSFFunction -Message 'Failed to convert block' -ErrorRecord $_ -Target $blockData -EnableException $true -Cmdlet $PSCmdlet }
                        $inBlock = $false
                    }
                    else { $blockData.Lines += $line }
                    
                    continue
                }
                #endregion Process Block Content
                
                #region Process Code Content
                if ($inCode) {
                    if ($line -like '``````*') {
                        $paragraph = @()
                        Add-SBLine '</pre>'
                        $inCode = $false
                        $firstPar = $true
                        continue
                    }
                    Add-SBLine $line
                    continue
                }
                #endregion Process Code Content
                
                #region Process Bullet Point
                if ($inBullet) {
                    if (-not $line.Trim()) {
                        Add-SBLine '<li class="defaultLI">{0}</li>' -Values ($paragraph -join ' ')
                        Add-SBLine '</ul>'
                        $paragraph = @()
                        $inBullet = $false
                        $firstPar = $true
                        continue
                    }
                    if ($line -notlike '+ *') {
                        $paragraph += $line
                        continue
                    }
                    
                    if ($paragraph) {
                        Add-SBLine '<li class="defaultLI">{0}</li>' -Values ($paragraph -join ' ')
                        $paragraph = @()
                    }
                    $paragraph += $line -replace '^\+ '
                    continue
                }
                #endregion Process Bullet Point
                
                #region Process Notes
                if ($inNote) {
                    if ($line.Trim()) {
                        $paragraph += $line -replace '^>\s{0,1}'
                        continue
                    }
                    
                    foreach ($text in ConvertFrom-EBMarkdown -Line $paragraph -ClassFirstParagraph noteFirstPar -ClassParagraph noteText -EmphasisClass noteEmphasis) {
                        Add-SBLine $text
                    }
                    Add-SBLine '<hr/></div>'
                    $inNote = $false
                    $firstPar = $true
                    $paragraph = @()
                    continue
                }
                #endregion Process Notes
                
                #region Process Paragraph
                if ($inParagraph) {
                    if ($line.Trim()) {
                        $paragraph += $line
                        continue
                    }
                    
                    $class = 'text'
                    if ($firstPar) {
                        $class = 'firstpar'
                        $firstPar = $false
                    }
                    
                    foreach ($text in ConvertFrom-EBMarkdown -Line $paragraph -ClassFirstParagraph $class -ClassParagraph $class) {
                        Add-SBLine $text
                    }
                    $paragraph = @()
                    $inParagraph = $false
                    continue
                }
                #endregion Process Paragraph
                
                #region Region Starters
                # Handle begin of a Block
                if ($line -like '## <*') {
                    $inBlock = $true
                    $blockData = New-Block -Line $line -Path $Path
                    continue
                }
                
                # Handle begin of a Code section
                if ($line -like '``````*') {
                    $inCode = $true
                    $firstPar = $true
                    Add-SBLine '<pre>'
                    continue
                }
                
                # Handle begin of a Bullet-Points section
                if ($line -like '+ *') {
                    $inBullet = $true
                    Add-SBLine '<ul>'
                    $paragraph += $line -replace '^\+ '
                    continue
                }
                
                # Handle begin of a Notes section
                if ($line -like '> *') {
                    $inNote = $true
                    Add-SBLine '<div class="notes"><hr/>'
                    $paragraph += $line -replace '^> '
                    continue
                }
                
                # Handle Chapter Title
                if ($line -like '# *') {
                    $null = $stringBuilder.AppendLine("<h2>$($line -replace '^# ')</h2>")
                    continue
                }
                
                # Handle begin of a Paragraph section
                if ($line.Trim()) {
                    $inParagraph = $true
                    $paragraph += $line
                }
                #endregion Region Starters
            }
            
            #region Cleanup
            
            #region Process Block Content
            if ($inBlock) {
                try { $firstPar = ConvertFrom-MdBlock -Type $blockData.Type -Lines $blockData.Lines -Attributes $blockData.Attributes -StringBuilder $stringBuilder }
                catch { Stop-PSFFunction -Message 'Failed to convert block' -ErrorRecord $_ -Target $blockData -EnableException $true -Cmdlet $PSCmdlet }
                $inBlock = $false
            }
            #endregion Process Block Content
            
            #region Process Code Content
            if ($inCode) {
                Add-SBLine '</pre>'
                $inCode = $false
                $firstPar = $true
            }
            #endregion Process Code Content
            
            #region Process Bullet Point
            if ($inBullet) {
                Add-SBLine '<li class="defaultLI">{0}</li>' -Values ($paragraph -join ' ')
                Add-SBLine '</ul>'
                $paragraph = @()
                $inBullet = $false
                $firstPar = $true
            }
            #endregion Process Bullet Point
            
            #region Process Notes
            if ($inNote) {
                foreach ($text in ConvertFrom-EBMarkdown -Line $paragraph -ClassFirstParagraph noteFirstPar -ClassParagraph noteText -EmphasisClass noteEmphasis) {
                    Add-SBLine $text
                }
                Add-SBLine '<hr/></div>'
                $inNote = $false
                $firstPar = $true
                $paragraph = @()
            }
            #endregion Process Notes
            
            #region Process Paragraph
            if ($inParagraph) {
                $class = 'text'
                if ($firstPar) {
                    $class = 'firstpar'
                    $firstPar = $false
                }
                
                foreach ($text in ConvertFrom-EBMarkdown -Line $paragraph -ClassFirstParagraph $class -ClassParagraph $class) {
                    Add-SBLine $text
                }
                $paragraph = @()
                $inParagraph = $false
            }
            #endregion Process Paragraph
            #endregion Cleanup
            
            New-Object EbookBuilder.Page -Property @{
                Index = $Index
                Name  = (Get-Item -Path $Path).BaseName
                Content = Close-SBStringBuilder -Name ebook
                SourceName = $Path
                TimeCreated = Get-Date
                MetaData = @{ }
            }
        }
        
        function ConvertFrom-InlineStyle {
            [OutputType([string])]
            [CmdletBinding()]
            param (
                [Parameter(ValueFromPipeline = $true)]
                [string[]]
                $Line,
                
                [hashtable]
                $InlineStyles = @{ }
            )
            
            begin {
                $replaceHash = @{ }
                
                foreach ($pair in $InlineStyles.GetEnumerator()) {
                    if ($pair.Value -is [string]) {
                        $replaceHash["#$($pair.Key)#(.+?)#$($pair.Key)#"] = '<span class="{0}">$1</span>' -f $pair.Value
                    }
                    else {
                        $newValue = '<span class="{0}">' -f $pair.Value.Class
                        if ($pair.Value.Prepend) { $newValue += $pair.Value.Prepend }
                        $newValue += '$1'
                        if ($pair.Value.Append) { $newValue += $pair.Value.Append }
                        $newValue += '</span>'
                        $replaceHash["#$($pair.Key)#(.+?)#$($pair.Key)#"] = $newValue
                    }
                }
            }
            process {
                foreach ($string in $Line) {
                    foreach ($pair in $replaceHash.GetEnumerator()) {
                        $string = $string -replace $pair.Key, $pair.Value
                    }
                    $string
                }
            }
        }
        
        function New-Block {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
            [CmdletBinding()]
            param (
                [string]
                $Line,
                
                [string]
                $Path
            )
            
            $type = $Line -replace '## <(\w+).+$', '$1'
            $attributes = @{ }
            $entries = $Line | Select-String '(\w+)="(.+?)"' -AllMatches
            foreach ($match in $entries.Matches) {
                $attributes[$match.Groups[1].Value] = $match.Groups[2].Value
            }
            
            [pscustomobject]@{
                Attributes = $attributes
                Type       = $type
                Lines       = @()
                File       = $Path
            }
        }
        
        $Index = 1
    }
    process {
        foreach ($pathItem in $Path) {
            Write-PSFMessage -Message "Processing: $pathItem"
            ConvertFrom-Markdown -Path $pathItem -Index $Index -InlineStyles $InlineStyles
            $Index++
        }
    }
}

function Read-EBMdBlockData {
<#
    .SYNOPSIS
        Parses lines of a markdown block into a structured content set.
     
    .DESCRIPTION
        Parses lines of a markdown block into a structured content set.
        This assumes the lines of strings provided are shaped in a structured manner.
     
        Example Input:
     
        > Classes
     
        + Hunter Level 10
        + Warrior Level 12
     
        > Skills
     
        Bash
        Slash
        Shoot
     
         
        This would then become a hashtable with two keys: Classes & Skills.
        Each line within each section would become the values of these keys.
     
    .PARAMETER Lines
        The lines of string to parse.
     
    .PARAMETER Header
        What constitutes a section header.
        This expects each header line to start with this sequence, followed by a whitespace.
     
    .PARAMETER IncludeEmpty
        Whether empty lines are included or not.
     
    .EXAMPLE
        PS C:\> $components = $Data.Lines | Read-EBMdBlockData
     
        Read all lines of string available in $Data, returns them as a components hashtable.
#>

    [OutputType([hashtable])]
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true)]
        [AllowEmptyCollection()]
        [string[]]
        $Lines,
        
        [string]
        $Header = '>',
        
        [switch]
        $IncludeEmpty
    )
    
    begin {
        $components = @{
            '_default' = @()
        }
        $currentComponent = '_default'
    }
    process {
        foreach ($line in $Lines) {
            if (-not $IncludeEmpty -and $line.Trim() -eq "") { continue }
            if ($line -notlike "$Header *") {
                $components.$currentComponent += $line
                continue
            }
            
            $componentName = $line -replace "^$Header "
            
            $currentComponent = $componentName
            $components[$currentComponent] = @()
        }
    }
    end {
        if (-not $components['_default']) { $components.Remove('_default') }
        $components
    }
}

function Read-EBMdDataSection {
<#
    .SYNOPSIS
        A simple string-data parser.
     
    .DESCRIPTION
        A simple string-data parser.
        Ignores empty lines.
        Skips lines that do not contain a ":" symbol.
        Will process each other line into key/value pairs, reading them as:
        <key>:<value>
        Each value will be trimmed and processed as string.
        Each key will be trimmed and have any leading "- " or "+ " elements removed.
     
    .PARAMETER Lines
        The lines of text to process.
     
    .PARAMETER Data
        An extra hashtable to merge with the parsing results.
     
    .EXAMPLE
        PS C:\> $Data.Lines | Read-EBMdDataSection -Data $Data.Attributes
     
        Parses all lines, merges them with the hashtable in $Data.Attributes and returns the resultant hashtable.
#>

    [OutputType([hashtable])]
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true)]
        [AllowEmptyString()]
        [string[]]
        $Lines,
        
        [hashtable]
        $Data = @{ }
    )
    
    begin
    {
        $result = @{ }
        $result += $Data
    }
    process
    {
        foreach ($line in $Lines | Get-SubString) {
            if (-not $line) { continue }
            if ($line -notlike "*:*") { continue }
            $name, $value = $line -split ":", 2
            $result[$name.Trim('-+ ')] = $value.Trim()
        }
    }
    end
    {
        $result
    }
}

function Read-EBMicrosoftDocsIndexPage
{
<#
    .SYNOPSIS
        Converts an index page of a Microsoft Docs into a book.
     
    .DESCRIPTION
        Converts an index page of a Microsoft Docs into a book.
        Resolves all links in the index.
     
    .PARAMETER Url
        The Url to the index page.
     
    .PARAMETER StartIndex
        Start Index the pages will begin with.
        Index is what Export-EBBook will use to determine page order.
     
    .EXAMPLE
        PS C:\> Read-EBMicrosoftDocsIndexPage -Url https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/best-practices-for-securing-active-directory
     
        Parses the Active Directory Security Best Practices into page and image objects.
#>

    [CmdletBinding()]
    Param (
        [string]
        $Url,
        
        [int]
        $StartIndex = 0
    )
    
    begin
    {
        $index = $StartIndex
    }
    process
    {
        $indexPage = Read-EBMicrosoftDocsPage -Url $Url -StartIndex $index
        $indexPage
        $index++
        
        $pages = $indexPage.Content | Select-String '<a href="(.*?)"' -AllMatches | Select-Object -ExpandProperty Matches | ForEach-Object { $_.Groups[1].Value }
        $basePath = (Split-Path $indexPage.SourceName) -replace "\\", "/"
        
        foreach ($page in $pages)
        {
            $tempPath = $basePath
            while ($page -like "../*")
            {
                $tempPath = (Split-Path $tempPath) -replace "\\", "/"
                $page = $page -replace "^../", ""
            }
            Read-EBMicrosoftDocsPage -Url ("{0}/{1}" -f $tempPath, $page) -StartIndex $index
            $index++
        }
    }
}


function Read-EBMicrosoftDocsPage
{
<#
    .SYNOPSIS
        Parses a web document from the Microsoft documents.
     
    .DESCRIPTION
        Parses a web document from the Microsoft documents.
     
    .PARAMETER Url
        The url of the website to parse.
     
    .PARAMETER StartIndex
        The index of the page. Used for sorting the pages when building the ebook.
     
    .EXAMPLE
        PS C:\> Read-EBMicrosoftDocsPage -Url https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/best-practices-for-securing-active-directory
     
        Parses the file of the specified link and converts it into a page.
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string[]]
        $Url,
        
        [int]
        $StartIndex = 1
    )
    
    begin
    {
        $index = $StartIndex
    }
    process
    {
        foreach ($weblink in $Url)
        {
            $data = Invoke-WebRequest -UseBasicParsing -Uri $weblink
            $main = ($data.RawContent | Select-String "(?ms)<main.*?>(.*?)</main>").Matches.Groups[1].Value
            $source, $title = ($main | Select-String '<h1.*?sourceFile="(.*?)".*?>(.*?)</h1>').Matches.Groups[1 .. 2].Value
            $text = ($main | Select-String '(?ms)<!-- <content> -->(.*?)<!-- </content> -->').Matches.Groups[1].Value.Trim()
            $content = "<h1>{0}</h1> {1}" -f $title, $text
            $webClient = New-Object System.Net.WebClient
            foreach ($imageMatch in ($content | Select-String '(<img.*?src="(.*?)".*?alt="(.*?)".*?>)' -AllMatches).Matches)
            {
                $relativeImagePath = $imageMatch.Groups[2].Value
                $imageName = $imageMatch.Groups[3].Value
                $imagePath = "{0}/{1}" -f ($weblink -replace '/[^/]*?$', '/'), $relativeImagePath
                $image = New-Object EbookBuilder.Image -Property @{
                    Data = $webClient.DownloadData($imagePath)
                    Name = $imageName
                    TimeCreated = Get-Date
                    Extension = $imagePath.Split(".")[-1]
                    MetaData = @{ WebLink = $imagePath }
                }
                $image
                $content = $content -replace ([regex]::Escape($relativeImagePath)), "../Images/$($image.FileName)"
            }
            
            New-Object EbookBuilder.Page -Property @{
                Index = $index++
                Name  = $title
                Content = $content
                SourceName = $weblink
                TimeCreated = Get-Date
                MetaData = @{ GithubPath = $source }
            }
        }
    }
}


function Read-EBRoyalRoad {
<#
    .SYNOPSIS
        Reads an entire series from Royal Road.
     
    .DESCRIPTION
        Reads an entire series from Royal Road.
        Converts it into the markdown format expected by Read-EBMarkdown.
     
    .PARAMETER Url
        The Url to the first chapter of a given Royal Road series
     
    .PARAMETER Name
        Name of the series
     
    .PARAMETER ConfigFile
        Path to a book project configuration file, replacing all the other parameters with values from it.
        For more details on configuration files, see New-EBBookProject.
     
    .PARAMETER Books
        A hashtable mapping page numbers as the start of a book to the name of that book.
        If left empty, there will only be one book, named for the series.
        Each page number key must an integer type.
     
    .PARAMETER OutPath
        The folder in which to create one subfolder per book, in which the chapter files will be created.
     
    .PARAMETER NoHeader
        The book does not include a header in the text portion.
        Will take the chapter-name as header instead.
     
    .PARAMETER ChapterOverride
        Chapters to skip.
        Intended for chapters where manual edits were performed and you do not want to overwrite them on the next sync.
     
    .EXAMPLE
        PS C:\> Read-EBRoyalRoad -Url https://www.royalroad.com/fiction/12345/evil-incarnate/chapter/666666/1-end-of-all-days -Name 'Evil Incarnate' -OutPath .
         
        Downloads the specified series, creates a folder in the current path and writes each chapter as its own .md file into that folder.
#>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingInvokeExpression", "")]
    [CmdletBinding(DefaultParameterSetName = 'Explicit')]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'Explicit')]
        [string]
        $Url,
        
        [Parameter(Mandatory = $true, ParameterSetName = 'Explicit')]
        [string]
        $Name,

        [Parameter(Mandatory = $true, ParameterSetName = 'Config')]
        [PsfValidateScript('PSFramework.Validate.FSPath.File', ErrorString = 'PSFramework.Validate.FSPath.File')]
        [string]
        $ConfigFile,
        
        [Parameter(ParameterSetName = 'Explicit')]
        [hashtable]
        $Books = @{ },
        
        [Parameter(ParameterSetName = 'Explicit')]
        [string]
        $OutPath,

        [Parameter(ParameterSetName = 'Explicit')]
        [switch]
        $NoHeader,
        
        [Parameter(ParameterSetName = 'Explicit')]
        [int[]]
        $ChapterOverride = @()
    )
    
    begin {
        $index = 1
        $bookCount = 1
        $replacements = @{ }
        $chaptersToSkip = $ChapterOverride
        
        #region Process Config File
        if ($ConfigFile) {
            $baseFolder = Split-Path -Path (Resolve-PSFPath -Path $ConfigFile)
            $config = Import-PSFPowerShellDataFile -Path $ConfigFile
            $Name = $config.Name
            $Url = $config.Url
            if ($config.StartIndex) { $index = $config.StartIndex }
            if ($config.BookIndex) { $bookCount = $config.BookIndex }
            if ($config.ContainsKey('HasTitle')) { $NoHeader = -not $config.HasTitle }
            if ($config.Books) { $Books = $config.Books }
            if ($config.ChapterOverride) { $chaptersToSkip = $config.ChapterOverride | Invoke-Expression | Write-Output }
            $OutPath = Join-Path -Path $baseFolder -ChildPath $config.OutPath

            if ($config.Replacements) {
                $replacementRoot = Join-Path -Path $baseFolder -ChildPath $config.Replacements
                foreach ($file in Get-ChildItem -Path $replacementRoot -Filter *.psd1) {
                    $entrySet = Import-PSFPowerShellDataFile -Path $file.FullName
                    foreach ($pair in $entrySet.GetEnumerator()) {
                        if (-not $replacements[$pair.Name]) { $replacements[$pair.Name] = @{ } }
                        foreach ($childPair in $pair.Value.GetEnumerator()) {
                            $replacements[$pair.Name][$childPair.Name] = [PSCustomObject]$childPair.Value
                        }
                    }
                }
            }
        }
        #endregion Process Config File
        
        if (-not $Books[1]) {
            $Books[1] = $Name
        }
        $currentBook = '{0} - {1}' -f $bookCount, $Books[$index]
        $currentBookPath = Join-Path -Path $OutPath -ChildPath $currentBook
        
        if (-not (Test-Path -Path $currentBookPath)) {
            $null = New-Item -Path $currentBookPath -Force -ItemType Directory -ErrorAction Stop
        }
    }
    process {
        $nextLink = $Url
        while ($nextLink) {
            Write-PSFMessage -Message 'Processing {0} Chapter {1} : {2}' -StringValues $Name, $index, $nextLink
            try { $page = Read-RRChapter -Url $nextLink -Index $index -NoHeader:$NoHeader -Replacements $replacements }
            catch { throw }
            $nextLink = $page.NextLink
            
            if ($index -notin $chaptersToSkip) {
                [System.IO.File]::WriteAllText(("{0}\{1}-{2:D4}-{3:D4}.md" -f $currentBookPath, $Name, $bookCount, $index), $page.TextMD)
                #$page.TextMD | Set-Content -Path ("{0}\{1}-{2:D4}-{3:D4}.md" -f $currentBookPath, $Name, $bookCount, $index) -Encoding UTF8
            }
            
            $index++
            if ($Books[$index]) {
                $bookCount++
                $currentBook = '{0} - {1}' -f $bookCount, $Books[$index]
                $currentBookPath = Join-Path -Path $OutPath -ChildPath $currentBook
                
                if (-not (Test-Path -Path $currentBookPath)) {
                    $null = New-Item -Path $currentBookPath -Force -ItemType Directory -ErrorAction Stop
                }
            }
        }
    }
}

function Register-EBMarkdownBlock
{
<#
    .SYNOPSIS
        Register a converter scriptblock for parsing block data with Read-EBMarkdown
     
    .DESCRIPTION
        Register a converter scriptblock for parsing block data with Read-EBMarkdown
     
        These allow you to custom-tailor and extend how special blocks are converted from markdown to html.
     
        The converter script receives one input object, which will contain three properties:
        - Type : What kind of block is being provided
        - Lines : The lines of text within the block
        - Attributes : Any attributes provided to the block
        - StringBuilder : The StringBuilder that you should append any lines of html to
     
        Your scriptblock should return a boolean value - whether the next paragraph should have the default indentation or be treated as a first line.
     
    .PARAMETER Name
        Name of the block.
        Equal to the html tag name used within markdown.
     
    .PARAMETER Converter
        Script logic performing the conversion.
     
    .EXAMPLE
        PS C:\> Register-EBMarkdownBlock -Name Warning -Converter $warningScript
     
        Registers a converter that will convert warning blocks to useful html.
#>

    [CmdletBinding()]
    param (
        [parameter(Mandatory = $true)]
        [string]
        $Name,
        
        [parameter(Mandatory = $true)]
        [System.Management.Automation.ScriptBlock]
        $Converter
    )
    
    process
    {
        $script:mdBlockTypes[$Name] = $Converter
    }
}