deploy.ps1
|
<# .SYNOPSIS `deploy.ps1` .DESCRIPTION `deploy.ps1` deploys a website. This is a simple and helpful scripting convention for site deployment. Just run `./deploy.ps1` `./deploy.ps1` can deploy any way we want. This `./deploy.ps1` dynamically generates a static site. Feel free to copy and paste this code. Please document your parameters, and add NOTES. .NOTES This deploys a static site by running any `*.*.ps1`. The output from each file will go into a corresponding file. For example: `a.css.ps1` will output `a.css` * `*.html.ps1` files will be piped into `layout` * `*.json.ps1` files will be converted to `json` `html`, `json`, and `xml` files will be made into `index` files (unless `-Ugly` links are preferred) #> [CmdletBinding(SupportsShouldProcess)] param( # An anlytics ID [string]$AnalyticsID, # The list of acceptable extensions [string[]] $AcceptableExtensions = @( 'css', 'md', 'html', 'js', 'json', 'svg', 'xml' ), # If set, will make "ugly" page links # This will make `a.html.ps1` generate `a.html`, instead of `/a/index.html` [switch] $Ugly, [string[]] $IndexExtension = @('html','xml','json'), # The root page url [uri] $PageUrl, # The site root [string] $SiteRoot ) # Know thyself $mySelf = $MyInvocation.MyCommand if (-not $SiteRoot) { $SiteRoot = $PSScriptRoot } # If there is no script root, if (-not $SiteRoot) { # error out. Write-Error "`$psScriptRoot is empty, Will not deploy" return } # Push into $psScriptRoot so everything is root relative. Push-Location $PSScriptRoot #region Map Parameters from Environment # Look at our environment foreach ($env in Get-ChildItem env:) { # See if any environment variables map to parameter # (after we remove any punctuation from the name) $envName = $env.Name -replace '\p{P}' if ( # If they map $mySelf.Parameters[$envName] -and # and are not already bound (-not $PSBoundParameters.ContainsKey($envName)) ) { # set them $ExecutionContext.SessionState.PSVariable.Set( $envName, $( # If the value looks like json if ($env.Value -match '^\s{0,}[\[\{]') { # convert it before we set the value. ConvertFrom-Json -InputObject $env.Value } # If the value is a boolean string elseif ($env.Value -match '^true|false$') { # convert it to a boolean $evn.Value -match 'true' } # Otherwise, directly map it. else { $env.Value } ) ) # After we set the variable, map it into `$psBoundParameters` $PSBoundParameters[$envName] = $ExecutionContext.SessionState.PSVariable.Get( $envName ).Value } } #endregion Map Parameters from Environment # Configs, Layouts, and Pages all may require modules # Make a little filter to import and install any module a script `#requires`. filter requireModule { # Our input should be a file. $in = $_ # Get the script at this location $command = Get-Command $in.FullName -CommandType ExternalScript # If that somehow failed, return if (-not $command) { return } # If the script has requirements foreach ($requirement in $command.ScriptBlock.Ast.ScriptRequirements.RequiredModules ) { # Check if they are loaded $requiredModule = Get-Module -ErrorAction Ignore -Name $requirement.Name # If they are not, if (-not $requiredModule) { # try to load the requirement "Importing Requirement $($requirement.Name) for $($in.FullName)" | Out-Host $requiredModule = Import-Module $requiredModule -Force -PassThru -ErrorAction Ignore # If that did not work, if (-not $requiredModule) { "Installing Requirement $($requirement.Name)" | Out-Host Install-Module $requirement.Name -Scope CurrentUser -Force Import-Module $requirement.Name -Global -Force -PassThru | Out-Host } } } } # Get whatever local module exists $psd1Path = Get-ChildItem -Filter *.psd1 | Select-String 'ModuleVersion' | Select-Object -ExpandProperty Path -First 1 # If one was found if ($psd1Path) { # import it "Importing $psd1Path" | Out-Host Import-Module $psd1Path -Force -PassThru | Out-Host } # Check for a layout script. $layout = Get-Command ./layout.ps1 -ErrorAction Ignore if ($layout) { # Import any requirements it might have if ($layout.ScriptBlock.Ast.ScriptRequirements) { [IO.FileInfo]$layout.Source | . requireModule } # And alias it to `layout`. Set-Alias layout $layout.Source } # Any `$site` metadata can be stored in a dictionary # Any of our parameters should be site wide metadata $site = [Ordered]@{} + $PSBoundParameters $Pages = [Ordered]@{} $PagesByUrl = [Ordered]@{} # A `config.ps1` file may do anything it wants to configure the site. # If there is a `.config.ps1` $configPs1 = Get-Command ./config.ps1 -ErrorAction Ignore if ($configPs1) { # Import any requirements it might have if ($configPs1.ScriptBlock.Ast.ScriptRequirements) { [IO.FileInfo]$configPs1.Source | . requireModule } . ./config.ps1 # and run it. } # We will be run any `*.*.ps1`(with an acceptable extension). # The output from each script will go to the corresponding file. # Declare some patterns we will use: # * `$matchExtension` will tell us which extension $matchExtension = '\.(?<x>[^\.]+)\.ps1$' # * `$acceptablePattern` will ensure we only build file types we accept $acceptablePattern = "\.(?>$($acceptableExtensions -join '|'))\.ps1$" # * `$indexPattern` indicates which file types will become indeces. $indexPattern = "\.(?>$($IndexExtension -join '|'))\.ps1$" # Get all files that match our pattern $files = @( Get-ChildItem -Path *.ps1 -File -Recurse | Where-Object Name -match $acceptablePattern ) | Sort-Object @{ # We want to generate Markdown files first # because html files may include their result inline. Expression = { # So sort on any md files first $_.Name -match '\.md' } Descending = $true # in descending order # (so they are at the top of the pile) }, Fullname # Prepare our progress bars $progress = @{ID=Get-Random;Activity="Building Pages"} $total = @($files).Length $counter = 0 # Walk over each of our files foreach ($in in $files) { $progress.PercentComplete = ++$counter * 100 / $total $progress.Status = $in.Name Write-Progress @progress # import any requirements $in | . requireModule $inScript = $ExecutionContext.SessionState.InvokeCommand.GetCommand($in.FullName, 'ExternalScript') # Make sure we can map the output extension $outputExtension = if ($in -match $matchExtension) { $matches.x } else { # otherwise, warn and continue. Write-Warning "Will not output $in without an extension" continue } # Default the title to the file name $FileName = $in.Name -replace $matchExtension $title = $FileName -replace '-', ' ' $help = Get-Help $in.FullName -ErrorAction Ignore $meta = [Ordered]@{} foreach ($attr in $inScript.ScriptBlock.Attributes) { if ($attr.Key -and $attr.Value) { $meta[$attr.Key] = $attr.Value } } # and initialize any page metadata $page = [Ordered]@{ Title = $title Command = $inScript File = $in FileName = $FileName Source = $inScript.ScriptBlock Help = $help Meta = $meta } $description = $help.description.text -join [Environment]::NewLine # 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 $in.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] } } } # If we map the output path _before_ we run our script # we can know what URL we will be publishing to. # Our output file path starts by replacing the .ps1 $outputPath = $in.FullName -replace '\.ps1$' # If we do not care about "pretty" url format # or are not an extension that we can make into an index, # we do not need to change the file path if ((-not $Ugly) -and ($in.Name -match $indexPattern)) { # If we do, things get a little more complicated # We will want to special case some extensions we want to use as an index. # If the name of the file matches the name of the directory if ($in.Name -match "^$([Regex]::Escape($in.Directory.Name))$matchExtension" ) { # it will become an index of that directory. $outputPath = Join-Path $in.Directory "index.$outputExtension" } else { # otherwise, make it an index of it's own directory $outputPath = ($in.FullName -replace $matchExtension) + "/index.$outputExtension" -replace 'index/index', 'index' } } # Now that we know our output path, # we can predict our url $page.Url = $outputPath -replace "^$( [Regex]::Escape($siteRoot) # just remove the site root )" -replace # replace any index with a slash '[\\/]index\.[^\.]+?$','/' -replace '[\\/]', '/' # and fix any slashes. $page.OutputPath = $outputPath $page.Extension = $outputExtension # Get our page output $output = @(. $in.FullName) if ($title) { $page.Title = $title } if ($description) { $page.Description = $description } # Store our output in the page $page.Output = $output # And put our page in two collections # One by output path (we will use this output) $Pages[$outputPath] = $page # and one by url $PagesByUrl[$page.Url] = $page } $progress.Activity = "Deploying Pages" $total = $pages.Count $counter = 0 foreach ($outputPath in $pages.Keys) { $progress.PercentComplete = ++$counter * 100 / $total $progress.Status = "$($page.Title) " Write-Progress @progress $page = $pages[$outputPath] $title = $page.Title $description = $page.Description $outputExtension = $page.Extension $output = $page.Output $outputFiles = @() $output = @(foreach ($out in $output) { if ($out -is [IO.FileInfo]) { $outputFiles += $out } else { $out } }) # If the output was a list of files if ($outputFiles -and -not $output) { # then do not write anything to disk $outputFiles continue } # If we have a layout file and are outputting html if ($layout -and ( $page.File.FullName -replace '\.ps1$' -match '\.html$' )) { # Pipe our output to the layout script. # By doing this in a second pass, # our layout script can have much more context. $output = $output | layout } # Otherwise, if our output extension is xml # and we have only that one xml elseif (($outputExtension -eq 'xml') -and ($output.Length -eq 1) -and ($output[0] -is [xml]) ) { # set our output to the outerXML. $output = $output.OuterXml } # Otherwise, if our output extension is json # and the output is not already a string elseif (($outputExtension -eq 'json') -and ( $output[0] -isnot [string] )) { # convert it into json. # If there is one more than one item, if ($output.Length -gt 1) { # make it a list $output = $output | ConvertTo-Json -Depth ( $FormatEnumerationLimit ) } else { # If there is only one item, make it an object. $output = ConvertTo-Json -InputObject $output -Depth ( $FormatEnumerationLimit ) } } $outputFile = [Ordered]@{ ItemType = 'File';Force = $true Value = $output -join [Environment]::NewLine Path = $outputPath } if ($WhatIfPreference) { $outputFile } elseif ($PSCmdlet.ShouldProcess("Output $($outputPath)")) { New-Item @outputFile } } if ($PageUrl) { $sitemap = /_includes/sitemap -Url $pageUrl -Pages $PagesByUrl if ($sitemap) { $sitemap.Save(( Join-Path $SiteRoot "sitemap.xml" )) Get-Item (Join-Path $SiteRoot "sitemap.xml") } } $progress.Remove('PercentComplete') $progress.Completed = $true Write-Progress @progress Pop-Location |