psturtle.com/buildPage.ps1

param(
[Parameter(ValueFromPipeline)]
[IO.FileInfo]
$File
)

$permalink = 'pretty'
$start = [datetime]::Now
$layoutAtPath = [Ordered]@{}
$layoutAtPathParameters = [Ordered]@{}
$allFiles = @($input)
if (-not $allFiles) { return}
$FileNumber = 0
$TotalFiles = $allFiles.Length
$progressId = Get-Random

if (-not $site) {
    $site = [Ordered]@{}
}

if (-not $site.Pages) {
    $site.Pages = [Ordered]@{}
}
$pages = $site.Pages

if (-not $site.PagesByUrl) {
    $site.PagesByUrl = [Ordered]@{}
}
$pagesByUrl = $site.PagesByUrl

:nextFile foreach ($file in $allFiles) {
    if ($Site -and $Site.Exclude) {
        $included = $false
        :exclude do {
            $excludePatterns = @() + $site.Exclude.Pattern + $site.Exclude.Patterns
            foreach ($excludePattern in $excludePatterns) {
                if (-not $excludePattern) { continue }
                if ($file.FullName -match $excludePattern) {
                    Write-Verbose "Excluding $($file.FullName) because it matches the exclude pattern '$excludePattern'."
                    break exclude
                }
            }
            $wildcardPatterns = @() + $site.Exclude.Wildcard + $site.Exclude.Wildcards
            foreach ($wildcardPattern in $wildcardPatterns) {
                if (-not $wildcardPattern) { continue }
                if ($file.FullName -like $wildcardPattern) {
                    Write-Verbose "Excluding $($file.FullName) because it matches the exclude wildcard '$wildcardPattern'."
                    break exclude
                }
            }
            $included = $true
        } until ($included)
    }
    $fileRoot = $file.Directory.FullName
    Push-Location $fileRoot
    # Get the file name by removing the extension.
    $fileName = $file.Name.Substring(0, $file.Name.Length - $file.Extension.Length)

    Write-Progress -Id $progressId -Status "Building Pages" "$($file.Name) " -PercentComplete ((++$FileNumber / $TotalFiles) * 100)
    # Initialize the page object
    $Site.Pages[$file.FullName] = $Page = [Ordered]@{
        # anything in MetaData should be rendered as <meta> tags in the <head> section.
        MetaData = [Ordered]@{}
        File = $file
    }

    # Generate a file date by:
    $fileDate = $fileName -replace 
        # * Remove any non-digit (except colon, dash, and underscore, and Z)
        '[^\d:-_Z]' -replace
            # * Trim leading punctuation, and trailing punctuation (and Z),
            '^\p{P}+' -replace '[-Z]+$' -replace
            # * replace underscores with colons, and try to cast to `[DateTime]`
            '_',':' -as [DateTime]
    
    # If we have a file date,
    if ($fileDate) {
        $page.Date = $fileDate # set the `$Page.Date`
    } else {
        # otherwise, we'll try to get the date from git.
        $gitCommand = $ExecutionContext.SessionState.InvokeCommand.GetCommand('git', 'Application')
        if ($gitCommand) {
            $gitDates = 
                try {
                    # we can use `git log --follow --format=%ci` to get the dates in order
                    (& $gitCommand log --follow --format=%ci --date default $file.FullName *>&1) -as [datetime[]]
                } catch {
                    $null
                }
            # Because the file might not be in git, we want to always set the `$LASTEXITCODE` to 0
            $LASTEXITCODE = 0
            # Set the date to the last date we find.
            if ($gitDates) {
                $page.Date = $gitDates[-1]                
            }            
        }
    }

    #region Data Files
    # We want to support data files for each potential page
    $dataFilePattern =
        # They are named the same as the file, but with an additional extension.
        # The extension is either json, psd1, or yaml.
        "^$([Regex]::Escape($file.Name))\.(?>json|psd1|ya?ml)$"

    # Find any data files
    $dataFiles =
        Get-ChildItem -Path $file.Directory.FullName |
        Where-Object Name -match $dataFilePattern

    # If we have a data file, we'll use it to set the page configuration.
    foreach ($dataFile in $dataFiles) {
        switch ($dataFile.Extension) {
            '.json' {
                $pageConfig = Get-Content -Path $dataFile.FullName -Raw | ConvertFrom-Json
                foreach ($property in $pageConfig.psobject.properties) {
                    $Page[$property.Name] = $property.Value
                }
            }
            '.psd1' {
                $pageConfig = Import-LocalizedData -FileName $dataFile.Name -BaseDirectory $file.Directory.FullName
                foreach ($property in $pageConfig.GetEnumerator()) {
                    $Page[$property.Key] = $property.Value
                }
            }
            '.yaml' {
                $pageConfig = Get-Item $dataFile.FullName | from_yaml
                foreach ($property in $pageConfig.GetEnumerator()) {
                    $Page[$property.Key] = $property.Value
                }
            }
        }
    }
    #endregion Data Files

    #region Get Page Content
    $outFile  = $file.FullName -replace '\.ps1$'

    $Output = $Content = $Page['Content'] = switch ($file.Extension) {
        # If it's a markdown file, we'll convert it to HTML.
        '.md' {
            $title = $Page['title'] = $file.Name -replace '\.md$' -replace 'index'
            $outFile = $file.FullName -replace '\.md$', '.html'
            $yamlHeader = $file | yaml_header
            if ($yamlHeader -is [Collections.IDictionary]) {
                foreach ($keyValue in $yamlHeader.GetEnumerator()) {
                    $page[$keyValue.Key] = $keyValue.Value
                }
            }
            $file | from_markdown
        }
        # If it's a typescript file, we'll compile it to JS.
        '.ts' {
            $outFile = $file.FullName -replace '\.ts$', '.js'
            tsc $file.FullName -module es6 -target es6
        }
        # If it's a powershell file, we'll probably run it.
        '.ps1' {
            # Unless the name is not like *.someExtension.ps1
            if ($file.Name -notlike '*.*.ps1') {
                Pop-Location
                continue nextFile
            }            
            # Get the script command
            $scriptCmd = Get-Command -Name $file.FullName
            # and install any requirements it has.
            $scriptCmd | RequireModule
            # Extract the title from the name of the file.
            $title = $Page['title'] = $file.Name -replace '\..+?\.ps1$' -replace 'index'
            
            #region Map File Parameters to Page and Site configuration
            $FileParameters = [Ordered]@{}
            :nextParameter foreach ($parameterName in $scriptCmd.Parameters.Keys) {
                $parameter = $scriptCmd.Parameters[$parameterName]
                $potentialType = $parameter.ParameterType
                foreach ($PotentialName in 
                    @($parameter.Name;$parameter.Aliases) -ne ''
                ) {
                    if ($page[$potentialName] -and $page[$potentialName] -as $potentialType) {
                        $FileParameters[$potentialName] = $page[$potentialName]
                        continue nextParameter
                    }
                    elseif ($site[$potentialName] -and $site[$potentialName] -as $potentialType) {
                        $FileParameters[$potentialName] = $site[$potentialName]
                        continue nextParameter
                    }
                }
            }
            try {
                $description = ''
                . $file @FileParameters
                if ($description -and -not $page.Description) {
                    $page.Description = $description
                }
                if ($title -and -not $page.Title) {
                    $page.Title = $title
                }                
            } catch {
                $errorInfo = $_
                "##[error]$($errorInfo | Out-String)"
                throw $errorInfo
            }
            
        }
    }
    #endregion Get Page Content
    
    # If we don't have output,
    if ($null -eq $Output) {
        Pop-Location
        continue nextFile # continue to the next file.
    }
    
    #region Prepare Layout
    
    # If we don't have a layout for this directory
    if (-not $layoutAtPath[$fileRoot] -and -not $page.Layout) {
        # go up until we find one.
        while ($fileRoot) {
            $layoutPath = Join-Path $fileRoot 'layout.ps1'
            # once we do
            if (Test-Path $layoutPath) {
                # set it in the hashtable
                $layoutAtPath[$fileRoot] =
                    $ExecutionContext.SessionState.InvokeCommand.GetCommand($layoutPath, 'ExternalScript')
                break # and take a break.
            }
            $fileRoot = $fileRoot | Split-Path
        }
    }

    $layoutParameters = [Ordered]@{}
    # If we have a layout for this directory, we'll use it.
    if ($page.Layout) {
        if ($page.Layout -is [Management.Automation.CommandInfo]) {
            if ($page.Layout -is [Management.Automation.ExternalScriptInfo]) {
                Set-Alias layout $page.Layout.Source
            } else {
                Set-Alias layout $page.Layout.Name
            }
        } elseif ($page.Layout -is [string]) {
            if ($site.layouts.($page.layout) -is [Management.Automation.ExternalScriptInfo]) {
                Set-Alias layout $site.layouts.($page.layout).Source
            } else {
                Set-Alias layout $page.Layout
            }            
        } elseif ($page.Layout -is [ScriptBlock]) {
            $function:PageLayout = $page.Layout
            Set-Alias layout PageLayout
        }
    } elseif ($layoutAtPath[$fileRoot]) {
        Set-Alias layout $layoutAtPath[$fileRoot].Source
    }
    
    # Get the current layout command
    $layoutCommand = $ExecutionContext.SessionState.InvokeCommand.GetCommand('layout', 'Alias')
    # If we found one, we will want to map parameters.
    if ($layoutCommand) {
        # This is somewhat straightforward, and opens up a lot of doors.
        # By allowing information in page or site to pass down to any command,
        # we can easily extend sites with simple scripts, and seamlessly integrate them.
        $layoutParameters = $layoutAtPathParameters[$fileRoot] = [Ordered]@{}

        # We have to walk over every parameter.
        # Since we want to exit the loop as soon as we have mapped a parameter,
        # we use a loop label, `:nextParameter`.
        :nextParameter foreach ($parameterName in $layoutCommand.Parameters.Keys) {
            $parameter = $layoutCommand.Parameters[$parameterName]
            $potentialType = $parameter.ParameterType
            # PowerShell parameters can have aliases, so lets find all potential names.
            $parameterNames = @($parameter.Name;$parameter.Aliases) -ne ''
            foreach ($PotentialName in $parameterNames) {
                # If the page has the parameter
                if ($page[$potentialName] -and
                    # and it is the right type
                    $page[$potentialName] -as $potentialType
                ) {
                    # We map the parameter from the page data.
                    $layoutParameters[$potentialName] = $page[$potentialName]

                    # We want to merge any dictionaries in both `$site` and `$page`.
                    if ($site[$potentialName] -and 
                        $site[$potentialName] -as $potentialType -and
                        $site[$potentialName] -is [Collections.IDictionary] -and
                        $page[$potentialName] -is [Collections.IDictionary]
                    ) {                        
                        # We copy in site wide data first.
                        # This will show site-wide entries
                        $layoutParameters[$potentialName] = [Ordered]@{}
                        foreach ($siteKey in $site[$potentialName].Keys) {
                            $layoutParameters[$PotentialName][$siteKey] = $site[$potentialName][$SiteKey]
                        }
                        # before page-specific entries.
                        # An entry in pages would overwrite an entry in the site.
                        foreach ($pageKey in $page[$potentialName].Keys) {
                            $layoutParameters[$potentialName][$pageKey] = $page[$potentialName][$pageKey]
                        }
                    }
                    # Now that we've mapped this parameter, continue to the `nextParameter`.
                    continue nextParameter
                }
                
                
                if (
                    # If we have the layout parameter in the `$site`
                    $site[$potentialName] -and 
                    # and it can be the right type
                    $site[$potentialName] -as $potentialType
                ) {
                    # Map it, and continue to the next parameter.
                    $layoutParameters[$potentialName] = $site[$potentialName]
                    continue nextParameter
                }
            }
        }
    }
    #endregion Prepare Layout

    #region Output
    # If we're outputting markdown, and it's not yet HTML
    if ($outFile -match '\.md$' -and $output -notmatch '<html') {
        $outputAsMarkdown = @($output) -join [Environment]::NewLine
        $Output = $outputAsMarkdown | from_markdown | layout @layoutParameters
    }

    # If we're outputting to html, let's do a few things:
    if ($outFile -match '\.html?$') {
        # If the output file was README, and we don't have an index.html,
        # we'll make README.html as index.html.
        if ($outFile -match 'README\.html$' -and -not (
            Test-Path ($outFile -replace 'README\.html$', 'index.html')
        )) {
            $outFile = $outFile -replace 'README\.html$', 'index.html'
        }

        # If the output file is named the same as the directory
        $directoryNamePattern = "$([Regex]::Escape($file.Directory.Name))\.html$"
        if (            
            $outFile -match $directoryNamePattern -and 
            -not ( # and there is no index.html,
                Test-Path ($outFile -replace $directoryNamePattern, 'index.html')
            ) -and -not ( # and there should not be one in the future
                Test-Path ($outFile -replace $directoryNamePattern, 'index.html.ps1')
            )
        ) {            
            # Then this will become the index.html.
            $outFile = $outFile -replace $directoryNamePattern, 'index.html'
        }

        if (
            $outFile -notmatch 'index\.html?$' -and 
            $outFile -notmatch '\d+\.html$' -and
            $permalink -eq 'pretty') {
                
            $outFile = $outFile -replace '\.html$', '/index.html'
        }                

        # If the output has outerXML
        if ($output.OuterXml) {
            # we'll put it in inline
            $output = $output.OuterXml
        }

        # * If the output is does not have an <html> tag,
        if (-not ($output -match '<html')) {
            # we'll use the layout.
            $output = $output | layout @layoutParameters
        }
    }

    # If the site has a root URL and script root,
    # we can predict the URL of the page, and store it in `$site.PagesByUrl`.
    if ($site.RootUrl -and $site.PSScriptRoot) {
        $urlLocalPath = # To determine the local path of the URL,
            $outFile -replace # replace the script root
                "^$([Regex]::Escape($site.PSScriptRoot))" -replace
                # and any remaining leading slashes,
                '^[\\/]' -replace 
                # then remove index.html from the end
                'index.html$'
        
        # Store the page in the hashtable
        $pagesByUrl[$site.RootUrl + $urlLocalPath] = $Page
    }
    
    if ($output -is [Data.DataSet]) {
        switch -regex ($outFile) {
            '\.json$' {
                $jsonObject = [Ordered]@{}
                foreach ($table in $output.Tables) {
                    if (-not $table.TableName) { continue }
                    $jsonObject[$table.TableName] = $table | 
                        Select-Object -Property $($table.Columns.ColumnName) 
                }
                $jsonObject | 
                    ConvertTo-Json -Depth ($FormatEnumerationLimit * 2) |
                    Set-Content -Path $outFile
                
                if ($?) {
                    $page.OutputFile = Get-Item -Path $outFile
                    $page.OutputFile
                    continue nextFile
                }                
            }
            '\.xml$' {
                $output.WriteXml("$outFile")
                if ($?) {
                    $page.OutputFile = Get-Item -Path $outFile
                    $page.OutputFile
                    continue nextFile
                }
            }
            '\.xsd$' {
                $output.WriteXmlSchema("$outFile")
                if ($?) {
                    $page.OutputFile = Get-Item -Path $outFile
                    $page.OutputFile
                    continue nextFile
                }
            }
        }
    }

    # If the output is json, and it's not yet json
    if ($outFile -match '\.json$' -and $output -isnot [string]) {
        # make it json
        $output = $output | ConvertTo-Json -Depth 10
    }
    
    # If the the output is XML,
    if ($output -is [xml]) {
        # save it
        $output.Save($outFile)
        # and if that worked,
        if ($?) {
            # output the file.
            $page.OutputFile = Get-Item -Path $outFile
            $page.OutputFile
        }
    }
    # If the output was a series of fileInfo objects
    elseif ($outputFiles = foreach ($out in $Output) 
    {
        if ($out -is [IO.FileInfo]) {
            $out
        }
    }) {
        # just output them directly.
        $outputFiles
    } else {
        # otherwise, we'll save output to a file.

        # If the file does not exists
        if (-not (Test-Path -Path $outFile)) {
            # create an empty file.
            $null = New-Item -Path $outFile -ItemType File -Force
        }

        $output > $outFile
        # and if that worked,
        if ($?) {
            # output the file.
            $page.OutputFile = Get-Item -Path $outFile
            $page.OutputFile
        }
    }
    #endregion Output

    Pop-Location
}

Write-Progress -Id $progressId -Completed -Status "Building Pages" "$($file.Name) " 
# we're done building files.
$end = [datetime]::Now
# so let everyone know how long it took.
Write-Host "File completed in $($end - $start)"