diff --git a/.github/workflows/BuildFun.yml b/.github/workflows/BuildFun.yml index a2f7c80..136cb03 100644 --- a/.github/workflows/BuildFun.yml +++ b/.github/workflows/BuildFun.yml @@ -2,91 +2,8 @@ name: Build Fun on: push: - pull_request: workflow_dispatch: jobs: - PowerShellStaticAnalysis: - runs-on: ubuntu-latest - steps: - - name: InstallScriptCop - id: InstallScriptCop - shell: pwsh - run: | - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Install-Module -Name ScriptCop -Repository PSGallery -Force -Scope CurrentUser - Import-Module ScriptCop -Force -PassThru - - name: InstallPSScriptAnalyzer - id: InstallPSScriptAnalyzer - shell: pwsh - run: | - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Install-Module -Name PSScriptAnalyzer -Repository PSGallery -Force -Scope CurrentUser - Import-Module PSScriptAnalyzer -Force -PassThru - - name: InstallPSDevOps - id: InstallPSDevOps - shell: pwsh - run: | - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Install-Module -Name PSDevOps -Repository PSGallery -Force -Scope CurrentUser - Import-Module PSDevOps -Force -PassThru - - name: Check out repository - uses: actions/checkout@v2 - - name: RunScriptCop - id: RunScriptCop - shell: pwsh - run: | - $Parameters = @{} - $Parameters.ModulePath = ${env:ModulePath} - foreach ($k in @($parameters.Keys)) { - if ([String]::IsNullOrEmpty($parameters[$k])) { - $parameters.Remove($k) - } - } - Write-Host "::debug:: RunScriptCop $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" - & {param([string]$ModulePath) - Import-Module ScriptCop, PSDevOps -PassThru | Out-Host - - if (-not $ModulePath) { - $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" - $ModulePath = ".\$moduleName.psd1" - } - if ($ModulePath -like '*PSDevOps*') { - Remove-Module PSDeVOps # If running ScriptCop on PSDeVOps, we need to remove the global module first. - } - - - $importedModule =Import-Module $ModulePath -Force -PassThru - - $importedModule | Out-Host - - $importedModule | - Test-Command | - Tee-Object -Variable scriptCopIssues | - Out-Host - - foreach ($issue in $scriptCopIssues) { - Write-GitHubWarning -Message "$($issue.ItemWithProblem): $($issue.Problem)" - } - } @Parameters - - name: RunPSScriptAnalyzer - id: RunPSScriptAnalyzer - shell: pwsh - run: | - Import-Module PSScriptAnalyzer, PSDevOps -PassThru | Out-Host - $invokeScriptAnalyzerSplat = @{Path='.\'} - if ($ENV:PSScriptAnalyzer_Recurse) { - $invokeScriptAnalyzerSplat.Recurse = $true - } - $result = Invoke-ScriptAnalyzer @invokeScriptAnalyzerSplat - - foreach ($r in $result) { - if ('information', 'warning' -contains $r.Severity) { - Write-GitHubWarning -Message "$($r.RuleName) : $($r.Message)" -SourcePath $r.ScriptPath -LineNumber $r.Line -ColumnNumber $r.Column - } - elseif ($r.Severity -eq 'Error') { - Write-GitHubError -Message "$($r.RuleName) : $($r.Message)" -SourcePath $r.ScriptPath -LineNumber $r.Line -ColumnNumber $r.Column - } - } TestPowerShellOnLinux: runs-on: ubuntu-latest steps: @@ -124,7 +41,6 @@ jobs: run: | $Parameters = @{} $Parameters.ModulePath = ${env:ModulePath} - $Parameters.PesterMaxVersion = ${env:PesterMaxVersion} $Parameters.NoCoverage = ${env:NoCoverage} $Parameters.NoCoverage = $parameters.NoCoverage -match 'true'; foreach ($k in @($parameters.Keys)) { @@ -143,9 +59,6 @@ jobs: # The module path. If not provided, will default to the second half of the repository ID. [string] $ModulePath, - # The Pester max version. By default, this is pinned to 4.99.99. - [string] - $PesterMaxVersion = '4.99.99', # If set, will not collect code coverage. [switch] @@ -573,6 +486,31 @@ jobs: steps: - name: Check out repository uses: actions/checkout@main + - name: PreviewFun + id: PreviewFun + shell: pwsh + run: | + <# + .SYNOPSIS + Preview Fun + .DESCRIPTION + + #> + if ($PSScriptRoot -notmatch '/_temp') { + Push-Location ($PSScriptRoot | Split-Path | Split-Path | Split-Path) + } + Import-Module .\Fun.psd1 -PassThru | Out-Host + $funFun = . .\Fun.fun.ps1 + $funFun.Deploy("./preview") + if ($PSScriptRoot -notmatch '/_temp') { + Pop-Location + } + - name: PreviewUpload + uses: actions/upload-artifact@main + with: + name: FunPreview + path: '**/preview/**' + if: ${{always()}} - name: Log in to the Container registry if: ${{github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'latest'}} uses: docker/login-action@master diff --git a/Build/Fun.GitHubWorkflow.PSDevOps.ps1 b/Build/Fun.GitHubWorkflow.PSDevOps.ps1 index e647c58..c0a52ad 100644 --- a/Build/Fun.GitHubWorkflow.PSDevOps.ps1 +++ b/Build/Fun.GitHubWorkflow.PSDevOps.ps1 @@ -5,8 +5,7 @@ Import-BuildStep -SourcePath ( Push-Location ($PSScriptRoot | Split-Path) -New-GitHubWorkflow -Name "Build Fun" -On Push, PullRequest, Demand -Job PowerShellStaticAnalysis, - TestPowerShellOnLinux, +New-GitHubWorkflow -Name "Build Fun" -On Push, Demand -Job TestPowerShellOnLinux, TagReleaseAndPublish, buildfun -OutputPath .\.github\workflows\BuildFun.yml -Env ([Ordered]@{ "REGISTRY" = "ghcr.io" diff --git a/Build/GitHub/Jobs/BuildFun.psd1 b/Build/GitHub/Jobs/BuildFun.psd1 index 0b538c3..57e6930 100644 --- a/Build/GitHub/Jobs/BuildFun.psd1 +++ b/Build/GitHub/Jobs/BuildFun.psd1 @@ -6,7 +6,21 @@ name = 'Check out repository' uses = 'actions/checkout@main' } + + 'PreviewFun' + + <#@{ + name = 'Upload Preview Artifact' + uses = 'actions/checkout@main' + with = @{ + name = 'preview' + path = './preview/**' + } + }#> + + 'PreviewFunUpload' + #> @{ 'name'='Log in to the Container registry' if = '${{github.ref_name == ''main'' || github.ref_name == ''master'' || github.ref_name == ''latest''}}' diff --git a/Build/GitHub/Steps/PreviewFun.ps1 b/Build/GitHub/Steps/PreviewFun.ps1 new file mode 100644 index 0000000..1ef1a15 --- /dev/null +++ b/Build/GitHub/Steps/PreviewFun.ps1 @@ -0,0 +1,14 @@ +<# +.SYNOPSIS + Preview Fun +.DESCRIPTION + +#> +if ($PSScriptRoot -notmatch '/_temp') { + Push-Location ($PSScriptRoot | Split-Path | Split-Path | Split-Path) +} +Import-Module .\Fun.psd1 -PassThru | Out-Host +. .\Fun.fun.ps1 deploy "preview" +if ($PSScriptRoot -notmatch '/_temp') { + Pop-Location +} \ No newline at end of file diff --git a/Build/GitHub/Steps/PreviewFunUpload.psd1 b/Build/GitHub/Steps/PreviewFunUpload.psd1 new file mode 100644 index 0000000..b86978c --- /dev/null +++ b/Build/GitHub/Steps/PreviewFunUpload.psd1 @@ -0,0 +1,10 @@ +@{ + name = 'PreviewUpload' + uses = 'actions/upload-artifact@main' + with = @{ + name = 'FunPreview' + path = '**/preview/**' + } + if = '${{always()}}' +} + diff --git a/CHANGELOG.md b/CHANGELOG.md index 95c2957..85a34e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,43 @@ +# Fun CHANGELOG + +## Fun Server 0.2 + +* Fun Server Improvements + * Not building wildcard functions ( #24 ) + * Binary http output (#46) + * The Server is the `$site` ( #57 ) + * Fun `-InitializeScript` ( #48 ) + * `Fun -Deploy` ( #51 ) + * `Fun -Parameter` ( #58 ) + * Fun` Path Parameters ( #60, #61, #62 ) + * More Fun Tests ( #60 ) + * `Fun -Layout` ( #65 ) + * Fun output `.ToString()` ( #53, re #24 ) + * Fun `$headers` and `$cookies` ( #40, #41 ) + * Fun with WebSockets and parameters ( #29, #32, #33, #34, #35, #36, #37, #38, #39 ) + * Fun streaming optional ( #28 ) + * Fun exact matching improvement ( #25 ) + * Fun `$body` and json support ( #26, #27 ) + * Fun prefers exact matches ( #25 ) + * Fun `$method` ( #23 ) + * Fun `POST` forms ( #22 ) + * Fun Verb Arguments ( #66 ) + * Fun ArgumentList passthru ( #66 ) + * Fun container passthru ( #68 ) +* Fun Website Improvements: + * `/_includes/FeatherIcon` ( #30 ) + * `/_includes/Palette` ( #44 ) + * `/_includes/HighlightScript` ( #43 ) + * `/_includes/HighlightColors` ( #45 ) + * `/_includes/CopyCode` ( #31 ) + * `layout.ps1` ( #20 ) + * `Fun.fun.ps1` updates ( #42 ) + * `PoshWeb.svg.ps1` ( #63 ) + * `Fun.css.ps1` ( #54, #59 ) + * Adding `*.*.ps1` and Markdown support ( #55, #56, #47 ) + +--- + ## Fun Server 0.1 * `Fun` is a fun functional PowerShell server (#1) diff --git a/Container.start.ps1 b/Container.start.ps1 index 7e4009b..a140fe9 100644 --- a/Container.start.ps1 +++ b/Container.start.ps1 @@ -37,27 +37,32 @@ function / { if ($args) { - # If there are arguments, output them (you could handle them in a more complex way). + # If there are arguments, + $remainingArgs = @(foreach ($arg in $args) { - if ($arg -match '\.ps1$' -and (Test-Path $arg)) { + # run any `.fun.ps1` that exists + if ($arg -match '\.fun\.ps1$' -and (Test-Path $arg)) { . $arg } else { - $arg + $arg } }) - $fun = fun "http://*/" @remainingArgs - # Launch three replicas - $fun.Start();$fun.Start();$fun.Start() + # and pass thru any remaining arguments. + $fun = fun @remainingArgs + $fun } else { if (Test-Path ./Fun.fun.ps1) { - . ./Fun.fun.ps1 + $fun = . ./Fun.fun.ps1 + $fun | Add-Member NoteProperty Prefixes "http://*/" -Force + $fun.Start();$fun.Start();$fun.Start(); } else { function / { "Hello from Fun" } + $fun = fun "http://*/" + # Launch three replicas + $fun.Start();$fun.Start();$fun.Start() } - $fun = fun "http://*/" - # Launch three replicas - $fun.Start();$fun.Start();$fun.Start() + } diff --git a/Fun.css.ps1 b/Fun.css.ps1 new file mode 100644 index 0000000..520ce3d --- /dev/null +++ b/Fun.css.ps1 @@ -0,0 +1,185 @@ +<# +.SYNOPSIS + Fun css +.DESCRIPTION + Generates a css stylesheet for fun. +.EXAMPLE + ./Fun.css.ps1 > ./Fun.css +#> +[OutputType('text/css')] +param( +# The Google Font name. +# This will be used for most elements. +[Alias('FontName')] +[string] +$Font = $( + if ($page.Font) { $page.Font } + elseif ($site.Font) { $site.Font } + elseif ($env:Font) { $env:Font } + else { 'Roboto'} +), + +# The header font name +# This will be used for `

`,`

`, `

` elements. +[string] +$HeaderFont = $( + if ($page.HeaderFont) { $page.HeaderFont } + elseif ($site.HeaderFont) { $site.HeaderFont } + elseif ($env:HeaderFont) { $env:HeaderFont } + else { 'Nunito Sans'} +), + +# The Google Code Font name +# This will be used for `
` elements.
+[string]
+$CodeFont = $(
+    if ($page.CodeFont) { $page.CodeFont }
+    elseif ($site.CodeFont) { $site.CodeFont }
+    elseif ($env:CodeFont) { $env:CodeFont }
+    else { 'Inconsolata' }
+)
+)
+# In this case we want a full screen body element  
+"body {"
+    @(
+        "max-width:100vw"
+        "height: 100vh"
+        "margin-left: auto"
+        "margin-right: auto"
+        # which displays inner content in a grid
+        "display:grid"        
+        # with autosized top and bottom and a flexibly sized middle
+        "grid-template-rows: auto, 1fr, auto"
+        # and used the font we provided (falling back to sans-serif).
+        "font-family: '$Font', sans-serif"        
+        "background-image:$(        
+            $alpha = "4.2%"
+            $colors = 'var(--foreground)','transparent'            
+            $randomPositions = foreach ($n in 1..(Get-Random -Min 2 -Max 5)) {
+                "$(
+                    Get-Random -Minimum 1 -Maximum 99
+                )% $(
+                    Get-Random -Minimum 1 -Maximum 99
+                )%"
+            }
+            @(foreach ($position in $randomPositions) {
+                "repeating-radial-gradient($(@(
+                    "ellipse $(
+                        Get-Random -Min 40 -Max 60
+                    )% $(
+                        Get-Random -Min 40 -Max 60
+                    )% at $position"
+                    $colorNumber = 0
+                    $bandSize = Get-Random -Minimum 4 -Maximum 8
+                    foreach ($color in $colors) {
+                        $colorNumber++
+                        "color-mix(in srgb, $color $alpha, transparent) $($colorNumber * $bandSize)rem"
+                    }                    
+                ) -join ',' + [Environment]::NewLine))"
+            }) -join (',' + [Environment]::NewLine)
+)" 
+
+    ) -join ';'
+"}"
+
+# Main content we want to "nudge in" a bit.
+".main { width: 80%; margin-left:auto; margin-right: auto }"
+
+# `h1`, `h2`, `h3` are centered with slight font size and line height adjustments
+"header h1, header h2, header h3 { text-align: center; font-family: '$HeaderFont', sans-serif; letter-spacing: 0.1rem; }"    
+
+"h1 { text-align: center; font-size: 4rem; line-height: 5rem}"
+"h2 { text-align: center; font-size: 2rem; line-height: 3rem}"
+"h3 { font-size: 1.5rem; line-height: 2rem }"
+
+# `footer`
+"footer {"
+    @(
+        "margin-top: 2vh;" # has a vertical margin to make it stand out.
+    ) -join ';'                
+    "ul li { list-style-type: none; display: inline; }"
+"}"
+
+".fixed { position: fixed; }"
+".bottom { bottom: 0 }"
+".top { top: 0 }"
+".left { left: 0 }"
+".right { right: 0 }"
+".end { margin-bottom: 3rem }"
+
+"button { border-radius: 4.2%; }"
+"select { border-radius: 4.2% }"
+
+# `footer menu`
+"footer menu {"
+    @(
+        # display as a grid
+        "display: grid"                    
+        "grid-template-columns: repeat(auto-fit, minmax(200px, 1fr))"
+        "text-align: center"
+        "place-items: center"
+        "justify-items: center"
+        "justify-content: space-between"
+    ) -join ';'
+"}"
+
+
+
+# `a` anchors do not get text decoration
+"a, a:visited { text-decoration: none; }"
+# unless they are focused or hovered
+"a:hover, a:focus {text-decoration: underline;}"
+
+# `select` and `button` should hover
+"select:hover, button:hover { cursor: pointer }"
+"* { box-sizing: border-box }"
+
+# The `header` should be a fixed grid
+"header {"
+    "position: fixed",
+    "display: grid",
+    "grid-template-areas: '$("header-left header-middle header-right")'",
+    "grid-template-columns: auto 1fr auto",
+    "padding: 0.5rem",
+    "top:0",
+    "width: 100%" -join ';'
+"}"
+
+"p {line-height: 1.5rem }"
+
+"@keyframes grow-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }"
+# Arrange the various headers
+".header-left{ grid-area: header-left; text-align: left; }"
+".header-middle { grid-area: header-middle; flex:1; text-align: center; }"
+".header-right { grid-area: header-right; text-align: right; margin-left: auto; }"
+".scroll-progress {"
+    "grid-area: header-middle",
+    "width: 100%",
+    "height: 1rem",
+    "margin-top: auto",
+    "margin-bottom: auto",
+    "transform-origin: 0 50%",    
+    "background: linear-gradient(to right, transparent, var(--foreground))",
+    "animation: grow-progress auto linear",
+    "animation-timeline: scroll()" -join ';'
+"}"
+
+# Render code and pre elements in our code font, fall back to monospace.
+"pre, code { font-family: '$CodeFont', monospace; }"
+"code { padding: 0.5rem }"
+
+".adknowledge {"
+    @(
+        "font-size: 0.9rem"
+        "margin-bottom: 1rem"
+        "text-align: center"
+        "display: grid"
+        "place-items: center"
+        "height: 4.2rem"
+    ) -join ';'
+"}"
+".adknowledge svg { height: 4.2rem }"
+
+# Include our highlight colors.
+/_includes/HighlightColors
+
diff --git a/Fun.fun.ps1 b/Fun.fun.ps1
index 2116ac8..9a26b10 100644
--- a/Fun.fun.ps1
+++ b/Fun.fun.ps1
@@ -1,3 +1,4 @@
+#requires -Module Fun
 
 # Fun Website
 if (-not $ExecutionContext.SessionState.InvokeCommand.GetCommand('layout', 'Alias')) {
@@ -7,14 +8,10 @@ if (-not $ExecutionContext.SessionState.InvokeCommand.GetCommand('layout', 'Alia
             ""
                 ''
                 "$([Web.HttpUtility]::HtmlEncode("$title"))"
-                ""
             ""
             ""
@@ -24,7 +21,69 @@ if (-not $ExecutionContext.SessionState.InvokeCommand.GetCommand('layout', 'Alia
         ""
         ) -join "`n"
     }
+}
+
+if ($PSScriptRoot) {
+    $layoutPath = Join-Path $PSScriptRoot layout.ps1
+    if (Test-Path $layoutPath) {
+        Set-Alias Layout $layoutPath
+    }
+    Push-Location $PSScriptRoot
+}
+
+#region Markdown Files
+# Next up are Markdown files
+$markdownFiles = @(Get-ChildItem -Path $PSScriptRoot -Filter *.md -File -Recurse)
+foreach ($markdownFile in
+    $markdownFiles) {
+    
+    # We _could_ create a function that reads the markdown
+    # But it will be faster to create a function that has already read the markdown. 
+
+    # Convert the contents from markdown
+    $markdown = Get-Content $markdownFile.FullName -Raw |
+        ConvertFrom-Markdown
+
+    # and determine the right relative path
+    $relativeName = $markdownFile.FullName.Substring(
+        $PSScriptRoot.Length
+    ) -replace '\.md$' -replace '[\\/]', '/'
+
+    # and the name of the markdown
+    $markdownName = $markdownFile.Name -replace '\.md$'
+
+    # Our function name is our relative path
+    $functionName = "$relativeName"
+
+    # We will use the function provider to create the function
+    # so we need to predetermine the function name.
+    $functionPath = "function:/$functionName"
+
+    # And then magically create a script
+    $scriptLines = @(
+        # Set a title
+        "`$title = '$($markdownName -replace
+            '-','\s' -replace
+            "'","''"
+        )'"
+        # embed the html and pass it to layout
+        "'$($markdown.Html -replace "'","''")'"
+    )
+
+    # Create the function
+    $ExecutionContext.SessionState.PSVariable.Set(
+        $functionPath,
+        ($scriptLines -join [Environment]::NewLine)
+    )
+    # and set an /index.html alias
+    Set-Alias "$functionName/index.html" $functionName
+}
+#endregion Markdown Files
 
+$includesPath = Join-Path $PSScriptRoot _includes
+
+foreach ($include in Get-ChildItem -Path $includesPath -Filter *.ps1) {
+    Set-Alias "/_includes/$($include.Name -replace '\.ps1$')" $include.FullName
 }
 
 function / {
@@ -37,55 +96,142 @@ function / {
 [OutputType('text/html')]
 param()
 
-$title = "Fun Server $(Get-Module Fun | Select-Object -ExpandProperty Version)"
+$funModule = Get-Module Fun
+$title = "Fun Server $($funModule | Select-Object -ExpandProperty Version)"
 
+$markdown = @(
 @"
+# $($funModule.Name)
+## $($funModule.Description)
+
+Fun is a fun functional server, written in PowerShell.
+
+Any function named with `/` is a server function.
+
+This makes servers incredibly simple.
+
+_Your function is your server_.
+
+Let's write Hello World:
+$(. /_includes/HighlightScript {function / {"Hello World"}})
+
+If we wanted to return a different content type, we can use the `[OutputType]` attribute.
+
+$(. /_includes/HighlightScript {function / {
+    [OutputType("text/plain")]
+    param()
+    "Hello World"
+}})
+
+This approach makes PowerShell web development simple and fun.
+
+We can use Fun to make static and dynamic websites.
+
+### Static Fun
+
+Static sites don't have to be set in stone.
+
+We can make static sites in fun simply by naming functions with an extension.
+$(. /_includes/HighlightScript {function /index.html {
+    $message = 'This is a website', 'Hello World' | Get-Random    
+    "

$message

" +}}) + +To "build" our page, we can run our function and redirect the output + +$(. /_includes/HighlightScript {/index.html > ./index.html}) + +To learn more, see [Fun Websites](/fun/website) + +### Fun Fun + +Fun's website is built in Fun. + +The source code is in [`/Fun.fun.ps1`](/Fun.fun.ps1) + +For example, the [current request](/) is handled by the function `/`: +"@ +) + +@( +$markdown -join [Environment]::NewLine | + ConvertFrom-Markdown | + Select-Object -ExpandProperty html -

Fun

+"
function /" +. /_includes/HighlightScript ( + [ScriptBlock]::Create( + ("function / {", + $myInvocation.MyCommand.ScriptBlock, + "}" -join [Environment]::NewLine) + ) +) +"
" -

A Fun PowerShell Server

+"

View Website Source" -

-Fun is a fun functional interactive PowerShell server. -

+"

How Fun Works

" -

-It maps a url to a PowerShell function. -

+# Functions can contain functions and filters +# These can let us write content using simple object pipelines +filter p { "

$_$args

" } -

-For example, the current request to $( - $request.Url.LocalPath -) is handled by: -

+@( + "Fun is defined in a single PowerShell script with no dependencies" -
-    
-$([Web.HttpUtility]::HtmlEncode($MyInvocation.MyCommand.ScriptBlock))
-    
-
+ "It outputs an object that serves functions and aliases named */*" -

-Fun is defined in a single PowerShell script: -

+ "It is cross-platform and works out of the box in PowerShell Core" -
+ "When we run the script, we return a server object. This is used to route functions and keep server state" + + "We can run this script with the argument start to start our server" + + "Or we can (./Fun.ps1).Start() our server" - Fun.ps1 + "When requests come in, we route them. We find the right function and call it" +) | p -
+"
Fun.ps1" - -$([Web.HttpUtility]::HtmlEncode( - $ExecutionContext.SessionState.InvokeCommand.GetCommand('Fun','Function').ScriptBlock -)) - +$funScript = + $ExecutionContext.SessionState.InvokeCommand.GetCommand( + 'Fun','Function' + ).ScriptBlock +. /_includes/HighlightScript $funScript -
-
-"@ | . Layout +"

Fun.ps1 is currently:

" + +"" +"" +) } function /get/command { @@ -106,7 +252,7 @@ function /get/command { } "" "" - ) | Layout + ) } Set-Alias /index.html / @@ -120,6 +266,11 @@ function /fun/state { } function /fun/website { + $title = 'Fun Websites' + + filter p { "

$_$args

" } + @( + "

" "Fun Websites" "

" @@ -127,79 +278,114 @@ function /fun/website { "Static Websites with Fun" "

" - "

" - "In a dynamic site, we run our functions on demand" - "

" + p "In a dynamic site, we run our functions on demand" + + p "To make a static site, we can just run our functions save their output to a file" + + /_includes/HighlightScript {/ > ./index.html} + + p "To make this easier, fun includes two methods: .Build() and .Deploy()" + + p "Build runs every function named *.*, and gives you their output as text" + + /_includes/HighlightScript { + # Fun .Build() + (fun).Build() + } + + p "Since Build is an approved verb, we can also" + + /_includes/HighlightScript { + # Build-Fun + Build-Fun + } + + p "We can also .Deploy() a build." - "

" - "To make a static site, we can just run our fun once and save it to a file" - "

" + p "This will run the .Build() and write all the files to disk, beneath the current directory." - "
"
-    ""
-    [Web.HttpUtility]::HtmlEncode("/ > ./index.html")
-    ""
-    "
" + p "This will Deploy the content as a static site" + + /_includes/HighlightScript { + # Deploy-Fun will build and deploy any *.* file. + # Existing files will be overwritten. + Deploy-Fun + } - "

" - "To make this easier, fun includes a

.Build()
method" - "

" - - "
"
-        ""
-            [Web.HttpUtility]::HtmlEncode("(fun).Build()")
-        ""
-    "
" + ) } function /fun/experiment { "

Fun Experiment

", "

Fun is an experiment

", - "

Fun is a fun server, and it is experimental and subject to change

" | - Layout + "

Fun is a fun server, and it is experimental and subject to change

" } -function /fun/security { - "$((ConvertFrom-Markdown -LiteralPath ( - Get-Module Fun | Split-Path | Join-Path -ChildPath "security.md" - )).Html)" | Layout +function /fun/website/source { + $title = "Fun Website Source Code" + "

$title

" + . /_includes/HighlightScript $site.Define } -Set-Alias /fun/security/index.html /fun/security - -function /fun/contributing { - "$((ConvertFrom-Markdown -LiteralPath ( - Get-Module Fun | Split-Path | Join-Path -ChildPath "contributing.md" - )).Html)"| - Layout +function /fun/website/code { + [OutputType('text/plain')] + param() + "$($site.Define)" } -Set-Alias /fun/contributing/index.html /fun/contributing +Set-Alias /fun/website.ps1 /fun/website/code + +# We can turn a number of files into endpoints +# First up is the easy case of `*.*.ps1` files +foreach ($fileToMount in + Get-ChildItem -Recurse -File -Filter *.*.ps1 -Path $PSScriptRoot) { + + # Skip any *.*.ps1 that does not have a web-friendly implied extension. + if ($fileToMount.Name -notmatch '\.(?>html|css|json|js|svg)\.ps1$') { + continue + } + + # Get the relative path + $relativePath = + $fileToMount.FullName.Substring($PSScriptRoot.Length) -replace + '\.ps1$' -replace '[\\/]','/' + + # Set an alias from our relative path to our file + Set-Alias $relativePath $fileToMount.FullName -function /fun/code-of-conduct { - ConvertFrom-Markdown -LiteralPath ( - Get-Module Fun | Split-Path | Join-Path -ChildPath "code_of_conduct.md" - ) | - Select-Object -ExpandProperty Html | - Layout + # If the file was an index, also mount to the base path + if ($relativePath -match '/index[^/]+$') { + Set-Alias ($relativePath -replace '/index[^/]+$') $fileToMount.FullName + } } -Set-Alias /fun/code-of-conduct/index.html /fun/code-of-conduct -function /get/fun { - [OutputType('text/plain')] +Set-Alias /main.css /Fun.css + +function /get/fun.ps1 { + [OutputType('text/x-powershell')] param() Get-Command Get-Fun | Select-Object -ExpandProperty ScriptBlock } -Set-Alias /get/fun.ps1 /get/fun function /404 { @( "

404

" "

" - ) | Layout + ) } +Set-Alias /404.html /404 + +. Fun -Parameter @{ + PaletteName = 'AdventureTime', 'Andromeda', 'Konsolas', 'Popping-and-Locking', 'Wez' | Get-Random + AnalyticsId = 'G-HLWZJJGDCP' + Layout = $ExecutionContext.SessionState.InvokeCommand.GetCommand('layout','Alias').ResolvedCommand.ScriptBlock +} @args + +if ($PSScriptRoot) { + Pop-Location +} # $fun # $fun = 1..10 | ./fun.ps1 some fun args diff --git a/Fun.ps1 b/Fun.ps1 index 723295f..31d223f 100644 --- a/Fun.ps1 +++ b/Fun.ps1 @@ -28,7 +28,6 @@ This allows for fun interations between the browser and the terminal. .NOTES - .NOTES This is a fun experimental server in PowerShell. It is build atop a design pattern: @@ -67,11 +66,16 @@ For these reasons, we want to run `Fun` locally on a random loopback port, or in a container with a constrained list of commands. - We also want to avoid code injection at all costs, - and only expose safe commands. + We also want to avoid code injection at all costs, and only expose safe commands. + + Fun also allows you to redefine how it serves content and outputs results. + + You should not need to do this in most scenarios, but it can be fun to mod an engine. + + Fun can run isolated when provided an -InitializeScript file. .EXAMPLE # Hello World server - / { "

hello world

" } + function / { "

hello world

" } Start-Fun .EXAMPLE @@ -91,10 +95,10 @@ Start-Fun - Pop-Location + Pop-Location #> [CmdletBinding(PositionalBinding=$false)] -[Alias('Start-Fun')] +[Alias('Start-Fun','Deploy-Fun','Build-Fun')] param( # A list of any arguments. # If an argument starts with `https?://`, @@ -108,13 +112,481 @@ param( $ArgumentList, # Any Input Object. - # This is currently passed on directly to a server instance. # Any function can reference this input with `$this.Input` [Parameter(ValueFromPipeline)] [Alias('Input')] [PSObject] -$InputObject +$InputObject, + +# If set, will not allow websocket requests. +# Requests to websockets will return 405 - Method Not Allowed. +[switch] +$NoWebSocket, + +# If set, will stream responses. +# This will stream output from running commands +[switch] +$Stream, + +# The size of websocket buffers. +# This is the maximum size of a message sent to a websocket. +[uint32] +$BufferSize = 64kb, + +# The depth used to serialize websocket responses to json. +[ValidateRange(1,100)] +[byte] +$JsonDepth = 5, + +# Any site wide parameters or metadata. +# If any keys match parameter names, will attempt to set the parameter. +[Alias('Parameters','Metadata')] +[Collections.IDictionary] +$Parameter, + +# The Script Block used to route requests +# This accepts a url as the first argument, +# and all other arguments as functions. +# It should return the function that best matches the url. +[ScriptBlock] +$Router = { + param() + $url, $functions = $args + if (-not $url) { return } + # We can have one of three possible names + + $exactNames = @( + # Fully qualified (i.e `function http://127.0.0.1/ {}` ) + $url.Scheme,'://', + $url.DnsSafeHost, + $url.LocalPath -join '' + # Host qualified (i.e `function example.com/ {}` ) + $url.DnsSafeHost, + $url.LocalPath -join '' + # Scheme qualified (i.e. `function http:// {}` ) + $url.Scheme,':/', + $url.LocalPath -join '' + # Locally qualified (i.e. `function / {} ) + $url.LocalPath + ) + + $localPath = $url.LocalPath + + [Array]::Reverse($functions) + + $exactMatches = # Simply -match the function to the exact names + @(@($functions) -match "^(?>$( + @(foreach ($exactName in $exactNames) { + [Regex]::Escape($exactName) + }) -join '|' + ))/?$") + + return @( + if ($exactMatches) { + $exactMatches[0] + } else { + foreach ($function in $functions) { + # We don't want to be too picky about ending slashes, + # so remove them from our function name. + $functionWildcard = $function.Name -replace + '/$' -replace + '/[\:\$][^/]+','/?*' + if ( + # If the local path is like our function name + $localPath -and ( + # we've found our function + $localPath -replace '/$' -like $functionWildcard + ) + ) { + # Break after the first function we find. + $function + break + } + } + } + ) +}, + +[ScriptBlock] +$GetFunctionFormData = { + param([uri]$url, [string]$body, [string]$contentType) + + $formData = [Ordered]@{} + if ($Url.Query) { + $parsedQueryString = [Web.HttpUtility]::ParseQueryString($Url.Query) + # Then copy over our parameters. + foreach ($queryParameter in $parsedQueryString.Keys) { + if (-not $queryParameter) { continue } + $formData[$queryParameter] = $parsedQueryString[$queryParameter] + if ($formData[$queryParameter] -match '^(true|false)$') { + $formData[$queryParameter] = $formData[$queryParameter] -match '^true' + } + } + } + + # If the content type is urlencoded and we have a body + if ($ContentType -eq 'application/x-www-form-urlencoded' -and $body) { + # Read the input + $parsedQueryString = [Web.HttpUtility]::ParseQueryString($Body) + foreach ($queryParameter in $parsedQueryString.Keys) { + if (-not $queryParameter) { continue } + $formData[$queryParameter] = $parsedQueryString[$queryParameter] + if ($formData[$queryParameter] -match '^(true|false)$') { + $formData[$queryParameter] = $formData[$queryParameter] -match '^true' + } + } + } + + return $formData +}, + +[Alias('GetFunctionArguments','GetFunctionArgs')] +[ScriptBlock] +$GetFunctionPathParameter = { + <# + .SYNOPSIS + Gets parameters from the path + .DESCRIPTION + Gets function parameters from the path segments. + #> + param( + [Parameter(Mandatory)]$Function, + [Parameter(Mandatory)][uri]$Url + ) + + # Break the name into segments after we + $nameSegments = @( + $function.Name -replace + # remove http and ws protocols + '^(?>http|ws)s?://' -replace + # replace any `*.*` before a slash + '^[^\.]+\..+/' -split + # and split them just after every / or the end + '(?<=(?>/|$))' + ) + + # If we want verbose information, trace out the name and url segments + if ($VerbosePreference -notin 'Ignore','SilentlyContinue') { + Write-Verbose "$($NameSegments -join "`t")" + Write-Verbose "$($url.Segments -join "`t")" + } + + $PathParameters = [Ordered]@{ + Function = $Function + Url = $Url + BoundParameters = [Ordered]@{} + } + + $PathParameters.UnboundArguments = @( + # To get arguments, we need to go thru each segment in the name + for ($nSegment = 0; $nSegment -lt $nameSegments.Length; $nSegment++) { + # and (potentially) map it to the corresponding request segment. + $requestSegment = + if ($nSegment -le $url.Segments.Count) { + $Url.Segments[$nSegment] + } else { + # If we are out of actual request segments, break + break + } + + $nameSegment = $nameSegments[$nSegment] + + $requestSegment = $requestSegment -replace '/' + + # If the name segment is a variable + if ($nameSegment -match '^[\:\$]') { + # it should be a parameter name. + $parameterName = $nameSegment -replace '^[\:\$]' + # and we should map the segment by name. + $PathParameters.BoundParameters[$parameterName] = $requestSegment + } + + # If the segment is a wildcard, we will map the segment positionally + if ($nameSegment -match '^\*/?$') { $requestSegment } + } + + $nSegment-- + + if ($nSegment -lt $url.Segments.Length) { + $range = $nSegment..($url.Segments.Length - 1) + foreach ($segment in $Url.Segments[$range]) { + $segment -replace '/' + } + } + ) + return $PathParameters +}, + +[ScriptBlock] +$GetFunctionQueryParameter = { + param($url) + +}, + +# Initialization scripts +# Providing initialization scripts will isolate the server. +# This should not expose any functions not defined in the initialization scripts. +# Any required modules should be imported. +[Alias('Init','IsolateScript')] +[ValidateScript({ + if ($_ -isnot [ScriptBlock] -and + $_ -isnot [Management.Automation.ExternalScriptInfo] -and + -not ($_ -match '\.ps1$' -and (Test-Path $_)) + ) { + throw "Must be ScriptBlock or External Script" + } + return $true +})] +[PSObject[]] +$InitializeScript, + +# A script block used to output http requests. +[ScriptBlock] +$HttpOutput = { + $allOutput = @($input) + $allOutputBytes = $allOutput -as [byte[]] + if (-not $allOutputBytes) { + $allContent = + @(foreach ($in in $allOutput) { + $inXml = $in.OuterXml + if ($inXml) {"$inXml"} + elseif ($($inHtml = $in.html;$inHtml)) {"$inHtml"} + elseif ($in.ToString.Invoke) {$in.ToString()} + }) + if ($response.ContentType -eq 'text/html' -and + $site.Layout -is [ScriptBlock]) { + + $allOutputBytes = $encoding.GetBytes( + ($allContent | . $site.Layout.GetNewClosure()) + ) + } else { + $allOutputBytes = $encoding.GetBytes($allContent -join '') + } + } + if ($allOutputBytes) { + # CGI requests just need to close + $response.Close( + $allOutputBytes,$false + ) + } else { + $response.Close() + } +}, + +# A script block used to stream http outputs. +# This will be used when `-Streaming` output. +[ScriptBlock] +$HttpStreamOutput = { + # If we are streaming a CGI request + begin { + # We need to set the protocol version + $response.ProtocolVersion = '1.1' + # and send chunked responses. + $response.SendChunked = $true + $outputStream = $response.OutputStream + } + process { + # Then we output each object + $in = $_ + if ($outputStream.CanWrite) { + $outBytes = $in -as [byte[]] + $buffer = if ($outBytes) { + $outBytes + } else { + $encoding.GetBytes( + $( + $inXml = $in.OuterXml + if ($inXml) {"$inXml"} + elseif ( + $($inHtml = $in.html;$inHtml) + ) {"$inHtml"} + elseif ($in.ToString.Invoke) { + $in.ToString() + } + ) + ) + } + + $outputStream.Write($buffer, 0, $buffer.Length) + $outputStream.Flush() + } else { + # If there was no output stream, emit the result. + $in + } + } + end { + # Close our response when the command is done + if ($response.Close) {$response.Close()} + } +}, + +# A Layout script block +# If this is provided, and the function outputs html, +# all non-streamed HTTP output will be passed to the layout script. +# Any output will be piped to the layout. +[ScriptBlock] +$Layout, + +# The server script. +# This should listen for requests and `.Run` with that context. +[ScriptBlock] +$ServerScript = { + param($server, [Collections.IDictionary]$IO = [Ordered]@{}) + # It will have a listener + $httpListener = $server.HttpListener + + foreach ($key in @($IO.Keys)) { + $ExecutionContext.SessionState.PSVariable.set($key, $IO[$key]) + } + + if ($server.Initialize) { + $server.Functions = + $ExecutionContext.SessionState.InvokeCommand.GetCommands('*/*','Function,Alias', $true) + } + + # and we can loop while it is listening + if (-not $server.Counter) { + $server | Add-Member NoteProperty Counter ([long]0) -Force + } + + :nextRequest while ($httpListener.IsListening) { + # Get the next context + $getContext = $httpListener.GetContextAsync() + # and wait until it's ready + while (-not $getContext.Wait(11)) { } + $context = $getContext.Result + # Run our function + # If we don't yet have a counter, create one. + $request, $response = $context.Request, $context.Response + + # Increment our counter + $server.Counter++ + + try { + $server.Run($context) + } catch { + $err = $_ + $response.StatusCode = 400 + $response.ContentType = 'text/plain' + $response.Close([Text.Encoding]::UTF8.GetBytes( + "$err$( + if ($err.Exception.InnerException) { [Environment]::Newline; $err.Exception.InnerException} + $err | Select-Object * | Out-String + )" + ), $false) + $err + } + } +}, + +# The Socket Job. +# This will run whenever a new socket is created. +# It should listen to results from that socket and output them +# It should run any functions associated with the socket and reply with their output. +[ScriptBlock] +$SocketJob = { + param($this, $socketInfo) + $webSocket = $socketInfo.WebSocket + $context = $socketInfo.Context + $request, $response = $context.Request, $context.Response + # If we had an initialization script, + # Locally scope our functions. + if ($this.InitializationScript) { + $this.Functions = + $ExecutionContext.SessionState.InvokeCommand.GetCommands( + '*/*','Function,Alias', $true + ) + } + + $url = $Request.Url + + # This loop will run as long as the websocket is open. + :WebSocketMessageLoop while ($websocket.State -eq 'Open') { + # Websockets fill a buffer of memory + $Buffer = [byte[]]::new($this.BufferSize) + # Get a segment containing our buffer + $Segment = [ArraySegment[byte]]::new($Buffer) + # and await the next message. + $receivingWebSocket = $webSocket.ReceiveAsync( + $Segment, [Threading.CancellationToken]::None + ) + + # use this tight loop to let us cancel the await if we need to. + while (-not $receivingWebSocket.Wait(11)) {} + + # If we had a problem, write an error. + if ($receivingWebSocket.Exception) { + Write-Error -Exception ( + $receivingWebSocket.Exception + ) -Category ProtocolError + continue + } + + # At this point we should have a json message, in UTF8 + $encoding = [Text.Encoding]::UTF8 + try { + # WebSocket buffers are "old school" null terminated strings + # So let's find the null terminator (the first 0) + $nullTerminator = $Buffer.IndexOf([byte]0) + # and let's get the message. + $messageString = if ($nullTerminator -ge 0) { + $encoding.GetString($Buffer, 0, $nullTerminator) + } else { + $encoding.GetString($Buffer, 0, $Buffer.Length) + } + + # Now let's convert it from json + $socketMessage = + if ($messageString) { + try { + ConvertFrom-Json -InputObject $messageString + } catch { + # if we could not, treat it as plain text. + "$messageString" + } + } else { $null } + + if ($socketMessage) { + $this.Run($socketInfo, $socketMessage) + } else { + $this.Run($socketInfo) + } + } catch { + Write-Error $_ + } + } +}, + +# The WebSocket Output. +# This will be called to output a result to a websocket. +[ScriptBlock] +$WebSocketOutput = { + $out = @($input) # Websockets just need to take all output + if ($out.Length -eq 1) { $out = $out[0] } + # and send it down the wire as json + $webSocket.SendAsync( + [ArraySegment[byte]]::new($encoding.GetBytes( + (ConvertTo-Json -InputObject $out -Depth $JsonDepth) + ) + ), 'Text', $true, [Threading.CancellationToken]::None) +}, + +# The WebSocket streaming output. +# This will be called to output a series of results to a websocket. +[ScriptBlock] +$WebSocketStreamOutput = { + process { + $out = @($_) # take each output + if ($out.Length -eq 1) { $out = $out[0] } + # and send it directly along to the socket. + $webSocket.SendAsync( + [ArraySegment[byte]]::new($encoding.GetBytes( + (ConvertTo-Json -InputObject $out -Depth $JsonDepth) + ) + ), 'Text', $true, [Threading.CancellationToken]::None) + } +} ) # This function is designed to be pretty performant, @@ -126,23 +598,50 @@ if (-not $allInput -and $InputObject) { } # We will be outputting a custom object named after ourself -$myTypeName = - $MyInvocation.MyCommand.Name -replace +$myTypeName = + $MyInvocation.MyCommand.Name -replace '\.ps1$' -replace '^.+?-' # (replacing the extension and any verb) -Update-TypeData -TypeName $myTypeName -Force -DefaultDisplayPropertySet 'CreatedAt','RequestRate','Functions' -# Create our output object -$outputObject = New-Object PSObject -Property ([Ordered]@{ - PSTypeName = $myTypeName - CreatedAt = [DateTime]::Now - # Fun fact: this kind of enumeration is always up to date - # We will not need to watch for new commands, this variable will always have them. - Functions = $ExecutionContext.SessionState.InvokeCommand.GetCommands('/*','Function,Alias', $true) - Arguments = $ArgumentList - Input = $allInput -}) | - # Extend our output with a script methods and properties - #region `.Build` +Update-TypeData -TypeName $myTypeName -Force -DefaultDisplayPropertySet ( + 'CreatedAt','Prefix','Functions' +) + +# Create a dictionary for our output object +$output = [Ordered]@{PSTypeName = $myTypeName} + +# Handle any defined `-Parameter` +$myParameters = $MyInvocation.MyCommand.Parameters +if ($Parameter.Count) { + foreach ($parameterName in $Parameter.Keys) { + # If the parameterName is one of this commands parameters + if ($myParameters[$parameterName]) { + # Try to bind the value + $ExecutionContext.SessionState.PSVariable.Set( + $parameterName, $Parameter[$parameterName]) + } else { + # Otherwise, set the key + $output[$parameterName] = $Parameter[$parameterName] + } + } +} + +# Bind all of our existing parameters +foreach ($key in $myParameters.Keys) { + $var = $ExecutionContext.SessionState.PSVariable.get($key) + if ($var) { $output[$key] = $var.Value } +} + +# Hard code a small number of fields: +$output.CreatedAt = [DateTime]::Now # * `.CreatedAt` +$output.Functions = $ExecutionContext.SessionState.InvokeCommand.GetCommands( + '*/*','Function,Alias', $true +) # * `.Functions` +$output.Arguments = $ArgumentList # * `.Arguments` +$output.Input = $allInput # * `.Input` + +# Create our object and extend it +$outputObject = New-Object PSObject -Property $output | + #region `.Build()` Add-Member ScriptMethod Build { <# .SYNOPSIS @@ -150,34 +649,80 @@ $outputObject = New-Object PSObject -Property ([Ordered]@{ .DESCRIPTION Builds the server into a static site. - Will build any `/` function whose name is like *.*. + Will build any `/` function whose name is like *.* + + Output a list of paths and the contents of the files. #> - param([string]$Path = $pwd) - $this.Functions | + param() + + # Collect our functions + $functions = @($this.Functions) + # and set our site + $site = $this + + # Walk over each function + $functions | . { process { $cmd = $_ + + # Only build functions named *.* if ($cmd.Name -notlike '*.*') { return } - $output = . $cmd - $path = Join-Path $pwd $cmd.Name - $newFile = [Ordered]@{ - Path = Join-Path "." "./$($cmd.Name -replace "^/")" - Value=$output -join [Environment]::NewLine + # that do not have wildcards or variables + if ($cmd.Name -match '[\*\:\$]') { return } + + # Try to run each command. + try { + # Collect it's output + $output = . $cmd + if ( + # If we have a layout script + ($site.Layout -is [ScriptBlock]) -and + ( + # and the output type is `text/html` + ($cmd.OutputType -match 'text/html') -or + (-not $cmd.OutputType) # or unspecified + ) + ) { + # Send our output to our layout. + $output = $output | . $site.Layout + } + } + catch { + Write-Warning "Error building $($cmd.Name): $_ " + return + } + + [Ordered]@{ + Path = "./$($cmd.Name -replace "^/")" + Value= if ($output -as [byte[]]) { + $output -as [byte[]] + } else { + $output -join [Environment]::NewLine + } } - New-Item @newFile -Force -ItemType File } } } -Force -PassThru | #endregion `.Build` + #region `.Clear` Add-Member ScriptMethod Clear { foreach ($func in $this.Functions) { - if ($func -is [Management.Automation.FunctionInfo]) { - Remove-Item "function:/$($func.Name)" - } elseif ($func -is [Management.Automation.AliasInfo]) { - Remove-Item "alias:/$($func.Name)" + try { + if ($func -is [Management.Automation.FunctionInfo]) { + Remove-Item "function:/$( + $func.Name -replace '\*','`*' -replace '\?', '`?' + )" + } elseif ($func -is [Management.Automation.AliasInfo]) { + Remove-Item "alias:/$( + $func.Name -replace '\*','`*' -replace '\?', '`?' + )" + } + } catch { + Write-Warning "Could not remove $($func.Name) - $_" } } } -Force -PassThru | - #endregion `.Clear + #endregion `.Clear` #region `.Define` Add-Member ScriptProperty Define { <# @@ -192,9 +737,7 @@ $outputObject = New-Object PSObject -Property ([Ordered]@{ if ($func -is [Management.Automation.FunctionInfo]) { "function $func {$( $func.ScriptBlock - )$( - [Environment]::NewLine - )}" + )$([Environment]::NewLine)}" } elseif ($func -is [Management.Automation.AliasInfo]) { "Set-Alias '$( $func.Name -replace "'","''" @@ -207,62 +750,64 @@ $outputObject = New-Object PSObject -Property ([Ordered]@{ ) } -Force -PassThru | #endregion `.Define` - #region `.JobScript` - Add-Member ScriptProperty JobScript { - return { - # All we need to do is pass this object - param($this) + #region `.Deploy` + Add-Member ScriptMethod Deploy { + <# + .SYNOPSIS + Deploys the server + .DESCRIPTION + Deploys the server as a static site. + + Will deploy any `/` function whose name is like *.* - # It will have a listener - $httpListener = $this.HttpListener - # and we can loop while it is listening - while ($httpListener.IsListening) { - # Get the next context - $getContext = $httpListener.GetContextAsync() - # and wait until it's ready - while (-not $getContext.Wait(13)) { } - $context = $getContext.Result - # If we don't yet have a counter - if (-not $this.Counter) { - # create one. - $this | - Add-Member NoteProperty Counter ([long]0) -Force - } - # Increment our counter - $this.Counter++ - # And run our function - if ($this.Run) { - try { - $this.Run($context) - } catch { - $err = $_ - $context.Response.StatusCode = 400 - $context.Response.Close([Text.Encoding]::UTF8.GetBytes( - "$err" - ), $false) - $err - } - } + Existing files will be overwritten. + #> + param([string]$Path = $pwd) + foreach ($fileBuilt in $this.Build()) { + $fileBuilt.Path = Join-Path $path $fileBuilt.Path + if ($fileBuilt.Value -is [byte[]]) { + $newFile = New-Item -Path $fileBuilt -Force + [IO.File]::WriteAllBytes($newFile.FullName, $fileBuilt.Value) + Get-Item -LiteralPath $fileBuilt + } else { + New-Item @fileBuilt -Force } + } + } -Force -PassThru | + #endregion `.Deploy` + #region `.Prefix` + Add-Member ScriptProperty Prefix { + if ($this.HttpListener.Prefixes.Length -eq 1) { + $this.HttpListener.Prefixes[0] + } else { + $this.HttpListener.Prefixes } } -Force -PassThru | - #endregion `.JobScript` + #endregion `.Prefix` + #region `.Remove` Add-Member ScriptMethod Remove { param([string]$Wildcard) if (-not $Wildcard) { return } foreach ($func in $this.Functions) { - if ($func.Name -notlike $Wildcard) { - continue - } - if ($func -is [Management.Automation.FunctionInfo]) { - Remove-Item "function:/$($func.Name)" - } elseif ($func -is [Management.Automation.AliasInfo]) { - Remove-Item "alias:/$($func.Name)" + if ($func.Name -notlike $Wildcard) { continue } + try { + if ($func -is [Management.Automation.FunctionInfo]) { + Remove-Item "function:/$( + $func.Name -replace '\*','`*' -replace '\?', '`?' + )" + } elseif ($func -is [Management.Automation.AliasInfo]) { + Remove-Item "alias:/$( + $func.Name -replace '\*','`*' -replace '\?', '`?' + )" + } + } catch { + Write-Warning "Could not remove $($func.Name) - $_" } } } -Force -PassThru | #endregion `.Remove + #region `.RequestRate` # We also want one script property that calculates a request rate Add-Member ScriptProperty RequestRate { @@ -272,110 +817,277 @@ $outputObject = New-Object PSObject -Property ([Ordered]@{ ([DateTime]::Now - $this.CreatedAt).TotalMinutes } -Force -PassThru | #endregion `.RequestRate` + #region `.Run` Add-Member ScriptMethod Run { <# .SYNOPSIS Run in a context .DESCRIPTION - Run the function in a context + Run the function in http request context or websocket context #> - param($context) + param( + # The context + $context, + + $socketMessage + ) # Allow for mock requests by enabling casting to uris if ($context -as [uri]) { $request = [Ordered]@{HttpMethod='Get';Url = $context -as [uri]} + } elseif ($context.context) { + $request, $response = $context.Context.Request, $context.Context.Response } else { $request, $response = $context.Request, $context.Response } - # Use the local path if present - $localPath = - if ($request.Url.LocalPath) { - $request.Url.LocalPath - } else { $null } + $site = $this + + # WebSocket handshake requests should be specially handled before we route to a function. + if ($Request.IsWebSocketRequest -and -not $context.WebSocket) { + if ($site.NoWebSocket) { + # Method not allowed + $response.StatusCode = 405 + $response.Close() + } + # We need to call the `AcceptWebSocketAsync` method to upgrade the connection. + $acceptWebSocket = $context.AcceptWebSocketAsync('json') + # We'll .Wait until the upgrade is finished. + while (-not $acceptWebSocket.Wait(11)) { } + # If it fails, + if ($acceptWebSocket.IsFaulted) { + # we will write an error and return. + Write-Error -Exception $acceptWebSocket.Exception -Category ProtocolError + return + } + # If it succeeds, capture the result. + $webSocketResult = try { $acceptWebSocket.Result } catch { $_ } + + # If we do not have any sockets on this object + if (-not $site.Sockets) { + # create a dictionary to hold them. + $site | Add-Member NoteProperty Sockets ([Ordered]@{}) -Force + } + + # If we do not have any sockets on this url + if (-not $site.Sockets[$request.Url]) { + # create a list + $site.Sockets[$request.Url] = @() + } + + # Prepare our socket info. + # This contains the initial request as well as web socket context. + $socketInfo = ([PSCustomObject]@{ + Context = $context + Request = $request + Response = $response + WebSocket = $webSocketResult.WebSocket + WebSocketContext = $webSocketResult + }) + + $request | + Add-Member NoteProperty Url ( + ($request.Url -replace '^http', 'ws') -as [uri] + ) -Force + $socketJobParams = [Ordered]@{ + Name = "$($request.Url)" + ArgumentList = $site,$socketInfo + ThrottleLimit = 32kb + ScriptBlock = $site.SocketJob + } + if ($site.InitializationScript) { + $socketJobParams.InitializationScript = $socketJobParams + } + # Each websocket runs in its own thread job + $socketJob = Start-ThreadJob @socketJobParams | + Add-Member NoteProperty HttpListener $site.HttpListener -Force -PassThru | + Add-Member NoteProperty SocketInfo $socketInfo -Force -PassThru | + Add-Member NoteProperty WebSocket $socketInfo.WebSocket -Force -PassThru | + Add-Member NoteProperty Url $socketInfo.Request.Url -Force -PassThru | + Add-Member NoteProperty Fun $site -Force -PassThru + + $urlString = "$($request.Url)" + $site.Sockets[$urlString] += $socketJob + + # While we're here, might as well clean up finished socket jobs. + $toRemove = @() + $site.Sockets[$urlString] = # Make one pass thru all sockets to this url + @(foreach ($socket in $site.Sockets[$urlString]) { + # If they are not completed or failed + if ($socket.State -notin 'Completed', 'Failed') { + $socket # keep it in the list + } else { + # otherwise, mark it for removal + $toRemove += $socket + } + }) + + # Remove only the jobs that completed without error. + $toRemove | Where-Object State -ne 'Failed' | Remove-Job + # Either way, return the socket job. + return $socketJob + } + + # * `$Method` should contain the HttpMethod + $Method = $request.HttpMethod + # * `$body` should contain the request body as a string + $body = '' # We want to match the url to a function. - $functions = @(foreach ($function in @($this.Functions)) { - # We don't want to be too picky about ending slashes, - # so remove them from our function name. - $functionNameNoSlash = $function.Name -replace '/$' - if ( - # If the local path is like our function name - $localPath -and - ( - # we've found our function - $localPath -replace '/$' -like $functionNameNoSlash - ) - ) { - # Break after the first function we find. - $function - break + $url = $request.Url + + $encoding = [Text.Encoding]::UTF8 + + $headers = [Ordered]@{} + if ($request.Headers) { + foreach ($key in $request.Headers.Keys) { + $headers[$key] = $request.Headers[$key] } - }) + } + + $cookies = [Ordered]@{} + foreach ($cookie in $request.Cookies) { + if (-not $cookie.Name) { continue } + $Cookies[$cookie.Name] = $cookie + } - # If there were no found functions - if (-not $functions) { + $ContentType = $request.ContentType + + $webSocket = $null + # This is only _slightly_ different for websocket requests. + # If the request is a websocket request, and we've got a live socket + if ($request.IsWebSocketRequest -and $context.WebSocket) { + $webSocket = $context.WebSocket + # and make $url reflect the new value + $url = $request.Url + $JsonDepth = $this.JsonDepth + } + + # Now that URL is properly defined (websocket or not) + # we can just `.Route` our functions. + $functions = @( + # (We just need to use use a new closure to avoid potential locks) + . $this.Router.GetNewClosure() $url $this.Functions + ) + + # If there were no found functions and this isn't a websocket request + if (-not $functions -and -not $request.IsWebSocketRequest) { # We're going to send a 404. - if ($response.StatusCode) { - $response.StatusCode = 404 - } + if ($response.StatusCode) { $response.StatusCode = 404 } # We want that 404 to be customizable, # so look for a function named the status code `(i.e. /404) $statusCodeFunction = @($this.functions -match "^/$($response.StatusCode)/?$") if ($statusCodeFunction) { - # If one existed, set `$functions` and call it normally. + # If one existed, set `$functions` and call it normally. $functions = $statusCodeFunction } else { # Otherwise, close the response - $response.Close() + $response.Close([Text.Encoding]::UTF8.GetBytes("No Fun @ $($request.Url)"), $false) return } } + # After we've routed, get the last matching function. + + $function = $functions[-1] + + # If we have not mapped a function, return. + if (-not $function) { return } + + # Http input streams can only be read once + # and we may want to read the content twice + # (or in two different ways) + $memoryStream = [IO.MemoryStream]::new() + $streamReader = $null + + if ($request.InputStream.CanRead) { + $request.InputStream.CopyTo($memoryStream) + } + + # If we have any input + if ($memoryStream.Length) { + $null = $memoryStream.Seek(0,'begin') + $streamReader = [IO.StreamReader]::new($memoryStream) + # read the body + $Body = $streamReader.ReadToEnd() + # seek back to 0 so we can read things again + $null = $memoryStream.Seek(0,'begin') + } + # To add to the fun, we want our functions to take parameters - $query = [Ordered]@{} - # If the request had a query - if ($request.Url.Query) { - # parse it - $parsedQueryString = [Web.HttpUtility]::ParseQueryString($request.Url.Query) - # and copy over our parameters. - foreach ($queryParameter in $parsedQueryString.Keys) { - $query[$queryParameter] = $parsedQueryString[$queryParameter] - if ($query[$queryParameter] -match '^(true|false)$') { - $query[$queryParameter] = $query[$queryParameter] -match '^true' - } + $FormData = . $site.GetFunctionFormData.GetNewClosure() $request.Url $body $ContentType + + $jsonData = [Ordered]@{} + # If the method is POST and we can read input + if ($body -and $ContentType -eq 'application/json') { + $parsedBody = ConvertFrom-Json -InputObject $body + foreach ($property in $parsedBody.psobject.properties) { + if (-not $property) { continue } + $jsonData[$property.Name] = $parsedBody.($property.Name) } } - - # Get the last matching function - $function = $functions[-1] - # And use its command metadata to find all possible parameters + # Get our path parameters + $pathParameters = @( + . $site.GetFunctionPathParameter.GetNewClosure() $function $url + ) + + # Create a map of all potential parameter names. $functionParameterMap = @{} - foreach ($parameter in ($function -as [Management.Automation.CommandMetadata]).Parameters.Values) { + foreach ($parameter in @(( + $function -as [Management.Automation.CommandMetadata] + ).Parameters.Values)) { + if (-not $parameter) { continue } + # PowerShell parameters have names $functionParameterMap[$parameter.Name] = $parameter + # but can also have any number of aliases. foreach ($alias in $parameter.Aliases) { $functionParameterMap[$alias] = $parameter } } - # Now take all of our query parameters + # Create a collection of all function parameters $functionParameters = [Ordered]@{} - foreach ($queryParameter in $query.Keys) { - # and map them to the function where we can - $functionParameter = $functionParameterMap[$queryParameter] - if ($functionParameter) { - $functionParameters[ - $functionParameter.Name - ] = $query[$queryParameter] + + # Go over every source of potential named parameters, + foreach ($namedParameters in + $FormData, + $jsonData, + $pathParameters.BoundParameters + ) { + if (-not $namedParameters) { continue } + # walk over each parameter name in the set, + foreach ($parameterName in $namedParameters.Keys) { + # check that it is a parameter + $functionParameter = $functionParameterMap[$parameterName] + if ($functionParameter) { + # and map the parameter to the value. + $functionParameters[ + $functionParameter.Name + ] = $namedParameters[$parameterName] + } } } + # If we passed a socket message, walk over its properties + if ($socketMessage -isnot [string] -and + $socketMessage -isnot [object[]] + ) { + foreach ($property in $socketMessage.psobject.properties) { + # and map them to the function where we can. + $functionParameter = $functionParameterMap[$property.Name] + if ($functionParameter) { + $functionParameters[$functionParameter.Name] = + $socketMessage.$($property.Name) + } + } + } + + # If the function had an output type like `*/*` if ($function.OutputType.Name -like '*/*' -and - $response.OutputStream - ) { + $response.OutputStream) { foreach ($outputType in $function.OutputType) { if ($outputType.Name -like '*/*') { # this will become the response content type @@ -384,95 +1096,87 @@ $outputObject = New-Object PSObject -Property ([Ordered]@{ } } } + # If we do not have an output type if (-not $function.OutputType) { # default to `text/html` $response.ContentType = 'text/html' } - - $functionOutput = { - begin { - # To stream output, we need to set the protocol version - $response.ProtocolVersion = '1.1' - # and send chunked responses. - $response.SendChunked = $true - # Get a pointer to the output stream for repeated use. - $outputStream = $response.OutputStream - $encoding = if ($request.ContentEncoding) { - $request.ContentEncoding + + # We need to determine how we will handle function output. + $FunctionOutput = + # We do not always want to stream content + if ($( + # we should only stream if `$this` or the `$function` say so. + $site.Stream + )) { + if ($request.IsWebSocketRequest) { + # If we are streaming a websocket request + $site.WebSocketStreamOutput } else { - [Text.Encoding]::UTF8 + $site.HttpStreamOutput } - } - - process { - # Then we need to take each output object - $in = $_ - - # If it is XML, - if ($in.OuterXml -and $outputStream.CanWrite) { - # write it out. - $buffer = $encoding.GetBytes("$($in.OuterXml)") - $outputStream.Write($buffer, 0, $buffer.Length) - $outputStream.Flush() - } - # If it has an HTML property - elseif ($in.html -and $outputStream.CanWrite) { - # write that out - $buffer = $encoding.GetBytes("$($in.html)") - $outputStream.Write($buffer, 0, $buffer.Length) - $outputStream.Flush() - } - # Otherwise - elseif ($outputStream.CanWrite) { - # Stringify the result. - $buffer = $encoding.GetBytes("$in") - $outputStream.Write($buffer, 0, $buffer.Length) - $outputStream.Flush() + } else { + # If we are not streaming, output is easier + if ($request.IsWebSocketRequest) { + $site.WebSocketOutput } else { - $in - } - } - - end { - # Close our response when the command is done - if ($response.Close) { - $response.Close() + $site.HttpOutput } } - } - + + $functionArgs = @($pathParameters.UnboundArguments) + $functionOutput = $functionOutput.GetNewClosure() # Call our function and stream the results try { - . $function @functionParameters *>&1 | - . $functionOutput + # If the function had positional parameters + if ($functionArgs) { + # pass them first + . $function @functionArgs @functionParameters *>&1 | + . $functionOutput + } else { + # otherwise, call our function with named parameters + . $function @functionParameters *>&1 | + . $functionOutput + } } catch { $err = $_ $response.StatusCode = 400 + $response.ContentType = 'text/plain' $response.Close([Text.Encoding]::UTF8.GetBytes( - "$err" + "$($err | Out-String)" ), $false) $err + } finally { + if ($streamReader) { + $streamReader.Close(),$streamReader.Dispose() + } + if ($memoryStream) { + $memoryStream.Close(),$memoryStream.Dispose() + } } - } -Force -PassThru | #endregion `.Run` + #region `.Start` Add-Member ScriptMethod Start { - param() + param([string]$Prefix) # In order to start the fun, we need an http listener if (-not $this.HttpListener) { # Attach this listener to this object - $this | - Add-Member NoteProperty HttpListener ( - [Net.HttpListener]::new() - ) -Force - # If we have any prefixes, add them - if ($this.Prefixes) { + $this | Add-Member NoteProperty HttpListener ( + [Net.HttpListener]::new() + ) -Force + # If we have any prefixes, add them. + if ($prefix) { + $httpPrefix = $prefix -replace '/{0,}$' -replace '$', '/' + $this.HttpListener.Prefixes.Add($httpPrefix) + } + elseif ($this.Prefixes) { foreach ($prefix in $this.Prefixes) { $httpPrefix = $prefix -replace '/{0,}$' -replace '$', '/' $this.HttpListener.Prefixes.Add($httpPrefix) - } + } } else { # Otherwise, pick a random local loopback port $this.HttpListener.Prefixes.Add( @@ -483,17 +1187,49 @@ $outputObject = New-Object PSObject -Property ([Ordered]@{ # Start the listener if (-not $this.HttpListener.IsListening) { - # Write a warning so we know something is listening + # and warn that something is listening Write-Warning "Listening on $($this.HttpListener.Prefixes)" $this.HttpListener.Start() } - $listener = $this.HttpListener - - # Now start our fun little server loop in a thread job. - $newJob = Start-ThreadJob -ScriptBlock $this.JobScript -ArgumentList $this -Name "$( - $listener.Prefixes -replace '/$' - )" -ThrottleLimit 16kb | + # Now start our fun little server loop in a thread job. + $newJob = [Ordered]@{ + ScriptBlock = $this.ServerScript;ArgumentList = $this + Name = "$($this.HttpListener.Prefixes -replace '/$')" + ThrottleLimit = 16kb + } + if ($this.InitializeScript) { + $initializationScript = [ScriptBlock]::Create( + @( + foreach ($initScript in $this.InitializeScript) { + if ($initScript.File -or $initScript -is [string]) { + $initScript = + $ExecutionContext.SessionState.InvokeCommand.GetCommand( + $( + if ($initScript.File) { + $initScript.File + } else { + $initScript + } + ), 'ExternalScript' + ) + } + if ($initScript -is [Management.Automation.ExternalScriptInfo]) { + foreach ($required in $initScript.ScriptBlock.Ast.ScriptRequirements.RequiredModules) { + "Import-Module $($required.Name) -Global" + } + ". '$($initScript.Source -replace "'","''")'" + } else { + ". {$initScript}" + } + } + ) -join [Environment]::NewLine + ) + $this | Add-Member NoteProperty Initialize $InitializationScript -Force + $newJob.InitializationScript = $initializationScript + } + + $newJob = Start-ThreadJob @newJob | Add-Member NoteProperty HttpListener $this.HttpListener -Force -PassThru | Add-Member NoteProperty Fun $this -Force -PassThru @@ -510,20 +1246,24 @@ $outputObject = New-Object PSObject -Property ([Ordered]@{ $prefixArguments = $ArgumentList -match '^https?://' if ($prefixArguments) { - $OutputObject | + $OutputObject | Add-Member NoteProperty Prefixes ( $prefixArguments -replace '/?$', '/' ) -Force } -# If the arguments contained `start` -if ($ArgumentList -contains 'Start' -or - # or the invocation name started with `Start-` - $MyInvocation.InvocationName -match '^Start-') { - # start the fun now. - $outputObject.Start() -} else { - # otherwise, output the fun - $outputObject + +foreach ($verb in 'Build', 'Deploy', 'Start') { + if ($ArgumentList -contains $verb -or + $MyInvocation.InvocationName -match "^$verb-") { + # Any other arguments should be passed to the verb + $remainingArgs = @( + foreach ($arg in $ArgumentList) { + if ($arg -ne $verb) {$arg } + } + ) + return $outputObject.$Verb.Invoke($remainingArgs) + } } -return \ No newline at end of file +# otherwise, output the fun +return $outputObject \ No newline at end of file diff --git a/Fun.psd1 b/Fun.psd1 index b092805..987d322 100644 --- a/Fun.psd1 +++ b/Fun.psd1 @@ -12,7 +12,7 @@ RootModule = 'Fun.psm1' # Version number of this module. -ModuleVersion = '0.1' +ModuleVersion = '0.2' # Supported PSEditions # CompatiblePSEditions = @() @@ -75,7 +75,7 @@ FunctionsToExport = 'Get-Fun', 'Fun' VariablesToExport = 'fun' # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. -AliasesToExport = 'Start-Fun' +AliasesToExport = 'Start-Fun', 'Build-Fun', 'Deploy-Fun' # DSC resources to export from this module # DscResourcesToExport = @() @@ -104,29 +104,43 @@ PrivateData = @{ # ReleaseNotes of this module ReleaseNotes = @' -## Fun Server 0.1 - -* `Fun` is a fun functional PowerShell server (#1) -* `Fun.ps1` defines a fun server (#2) -* `Fun.psm1` defines eponyoms for Fun.ps1 (#3) -* `Fun` has a build workflow (#4) -* `Fun` has tests (#6) -* `Fun` GitHub common documents - * `Fun` `security.md` (#5) - * `Fun` `contributing.md` (#7) - * `Fun` `code_of_conduct.md` (#8) -* `Fun` can run in a container - * Dockerfile (#9) - * Container.init.ps1 (#10) - * Container.start.ps1 (#11) - * Container.stop.ps1 (#12) -* Example Site: - * `Fun.fun.ps1` (#13) -* Sponsorship support (#14) -* GitHub Page - * `deploy.ps1` (#15) - * `deploy.yml` (#16) -* README.md.ps1 (#17) +## Fun Server 0.2 + +* Fun Server Improvements + * Not building wildcard functions ( #24 ) + * Binary http output (#46) + * The Server is the `$site` ( #57 ) + * Fun `-InitializeScript` ( #48 ) + * `Fun -Deploy` ( #51 ) + * `Fun -Parameter` ( #58 ) + * Fun` Path Parameters ( #60, #61, #62 ) + * More Fun Tests ( #60 ) + * `Fun -Layout` ( #65 ) + * Fun output `.ToString()` ( #53, re #24 ) + * Fun `$headers` and `$cookies` ( #40, #41 ) + * Fun with WebSockets and parameters ( #29, #32, #33, #34, #35, #36, #37, #38, #39 ) + * Fun streaming optional ( #28 ) + * Fun exact matching improvement ( #25 ) + * Fun `$body` and json support ( #26, #27 ) + * Fun prefers exact matches ( #25 ) + * Fun `$method` ( #23 ) + * Fun `POST` forms ( #22 ) + * Fun Verb Arguments ( #66 ) + * Fun ArgumentList passthru ( #66 ) + * Fun container passthru ( #68 ) +* Fun Website Improvements: + * `/_includes/FeatherIcon` ( #30 ) + * `/_includes/Palette` ( #44 ) + * `/_includes/HighlightScript` ( #43 ) + * `/_includes/HighlightColors` ( #45 ) + * `/_includes/CopyCode` ( #31 ) + * `layout.ps1` ( #20 ) + * `Fun.fun.ps1` updates ( #42 ) + * `PoshWeb.svg.ps1` ( #63 ) + * `Fun.css.ps1` ( #54, #59 ) + * Adding `*.*.ps1` and Markdown support ( #55, #56, #47 ) + +--- '@ diff --git a/Fun.tests.ps1 b/Fun.tests.ps1 index e23cd3b..172254b 100644 --- a/Fun.tests.ps1 +++ b/Fun.tests.ps1 @@ -1,9 +1,88 @@ + describe Fun { + BeforeAll { + New-Module -Name Foo -ScriptBlock { + function /hi { "Hello from Fun" } + function /query { param([int]$Number) $Number } + + function /form { param([int]$Number) $Number } + + function /json { param([int]$Number) $Number } + + function /args/* { $args } + + function /named/:named { + param($named) + $Named + } + + function /socket { + param( + [double]$Number + ) + + if ($request.IsWebSocketRequest) { + if ($Number) { + [Ordered]@{number = $Number} + } else { + [Ordered]@{number = Get-Random} + } + } else { + + } + } + } | Import-Module -Global + + $global:funJob = Start-Fun + Set-Alias Invoke-Rest Invoke-RestMethod + } it 'Is Fun To Make a Server' { - function / { "Hello from Fun" } + Invoke-Rest "$($global:funJob.Name)/hi" | + Should -Be "Hello from Fun" + } + + it 'Is easy to map query strings' { + $randomNumber = [Random]::new().next() + $funServer = Start-Fun -InitializeScript { + function /query { param([int]$Number) $Number } + } + Invoke-Rest ( + "$($funServer.Name)/query/?number=$randomNumber" + ) | Should -Be "$randomNumber" + $funServer | Stop-Job + } + + it 'Can map form data' { + $randomNumber = [Random]::new().next() + $funServer = Start-Fun -InitializeScript { + function /form { param([int]$Number) $Number } + } + Invoke-Rest "$($funServer.Name)/form" -Method POST -Body "number=$randomNumber" -ContentType ( + 'application/x-www-form-urlencoded' + ) | Should -Be "$randomNumber" + $funServer | Stop-Job + } + + it 'Can map json data' { + $randomNumber = [Random]::new().next() + $funServer = Start-Fun -InitializeScript { + function /json { param([int]$Number) $Number } + } + Invoke-Rest "$($funServer.Name)/json" -Method POST -Body ( + @{number=$randomNumber} | ConvertTo-Json + ) -ContentType ( + 'application/json' + ) | Should -Be "$randomNumber" + $funServer | Stop-Job + } + + it 'Can map positional arguments' { + $randomNumber = [Random]::new().next() + Invoke-Rest "$($global:funJob.Name)/args/$randomNumber" | Should -Be "$randomNumber" + } - Invoke-RestMethod ( - Start-Fun | Select-Object -ExpandProperty Name - ) | Should -Be "Hello from Fun" + it 'Can map positional named parameters' { + $randomNumber = [Random]::new().next() + Invoke-Rest "$($global:funJob.Name)/named/$randomNumber/" | Should -Be "$randomNumber" } } diff --git a/PoshWeb.svg.ps1 b/PoshWeb.svg.ps1 new file mode 100644 index 0000000..49cb222 --- /dev/null +++ b/PoshWeb.svg.ps1 @@ -0,0 +1,74 @@ +param( +[string] +$Variant = '', + +[int[]] +$Sequence, + +[int] +$First +) + +$psChevron = ' + +' + +$stroke = @( + "stroke='#4488ff'" + "class='foreground-stroke'" + "stroke-width='0.5%'" +) +$transparentFill = "fill='transparent'" +$animationLoop = "repeatCount='indefinite'" + $centered = "cx='50%'", "cy='50%'" + +"" + $psChevron + + $sequence = 42, 23, 16, 15, 8, 4 + $primes = 5, 7, 11, 13, 17, 19, 23 + for ($index = 0; $index -lt ($sequence.Length - 1); $index++) { + + $n = $sequence[$index] + $duration = "dur='$($primes[$primes.Length - 1 - $index])s'" + if (-not $index) { + "" + } + else { + if ($variant -match 'animate') { + $values = " values='$( + if ($index -eq 1) { + "$n%", "$($sequence[0])%", "$n%" -join ';' + } else { + "$n%", "$($sequence[0])%", "$n%" -join ';' + })'" + } + + $opacity = 1 - (.2 * $index) + + "" + if ($variant -match 'animate') { + + "" + "" + } + "" + "" + if ($variant -match 'animate') { + "" + } + if ($variant -match 'animate') { + "" + } + "" + } + if ($First -and ($index + 1) -gt $First) { + break + } + } + "" +"" diff --git a/_includes/CopyCode.ps1 b/_includes/CopyCode.ps1 new file mode 100644 index 0000000..3440e22 --- /dev/null +++ b/_includes/CopyCode.ps1 @@ -0,0 +1,22 @@ +<# +.SYNOPSIS + Includes a CopyCode button +.DESCRIPTION + Includes a copy-to-clipboard near all code blocks. +#> +[OutputType('text/html')] +param() +"" +" + +" \ No newline at end of file diff --git a/_includes/FeatherIcon.ps1 b/_includes/FeatherIcon.ps1 new file mode 100644 index 0000000..3444055 --- /dev/null +++ b/_includes/FeatherIcon.ps1 @@ -0,0 +1,44 @@ +<# +.SYNOPSIS + Includes Feather Icons +.DESCRIPTION + Includes a feather icon in the site. +.NOTES + Icons will be cached in memory to avoid repeated CDN requests. +.EXAMPLE + /_includes/FeatherIcon clipboard +.LINK + https://feathericons.com/ +#> +[OutputType('image/svg+xml')] +param( +# The feather icon name +[string] +$Icon = 'terminal', + +[uri] +$FeatherCDN = "https://cdn.jsdelivr.net/gh/feathericons/feather@latest/icons/" +) + +if (-not $script:FeatherIconCache) { + $script:FeatherIconCache = [Ordered]@{} +} + +$iconUri = + ( + $FeatherCDN -replace '^https?://' -replace '^', + 'https://' -replace '/$' + ), ( + $icon.ToLower() -replace '\.svg$' -replace '^/' -replace '$' -replace '\s', + '-' -replace '$', '.svg' + ) -join '/' + +if (-not $script:FeatherIconCache[$iconUri]) { + $script:FeatherIconCache[$iconUri] = try { + Invoke-RestMethod $iconUri + } catch { + Write-Warning "Could not get $iconUri : $_" + } +} + +$script:FeatherIconCache[$iconUri].OuterXml diff --git a/_includes/HighlightColors.ps1 b/_includes/HighlightColors.ps1 new file mode 100644 index 0000000..01599ce --- /dev/null +++ b/_includes/HighlightColors.ps1 @@ -0,0 +1,82 @@ +<# +.SYNOPSIS + Includes Highlight Colors +.DESCRIPTION + Includes Highlight Colors for PowerShell Scripts +#> +[OutputType('text/css')] +param( +# PowerShell Attribute css properties +[psobject]$Attribute = 'color: var(--cyan); font-weight: bold;', +# PowerShell Command css properties +[psobject]$Command = 'color: var(--cyan); font-weight: bold;', +# PowerShell CommandArgument css properties +[psobject]$CommandArgument = "color: var(--cyan); font-weight: bolder;", +# PowerShell CommandParameter css properties +[psobject]$CommandParameter = 'color: var(--brightCyan); font-weight: bold;', +# PowerShell Comment css properties +[psobject]$Comment = "color: var(--green);", +# PowerShell GroupEnd css properties +[psobject]$GroupEnd = 'color: var(--purple); font-weight: bolder;', +# PowerShell GroupStart css properties +[psobject]$GroupStart = 'color: var(--purple); font-weight: bolder;', +# PowerShell Keyword css properties +[psobject]$Keyword = "color: var(--cyan); font-weight: bolder;", +# PowerShell LineContinuation css properties +[psobject]$LineContinuation, +# PowerShell LoopLabel css properties +[psobject]$LoopLabel = "color: var(--brightCyan); font-weight: bold;", +# PowerShell Member css properties +[psobject]$Member, +# PowerShell NewLine css properties +[psobject]$NewLine, +# PowerShell Number css properties +[psobject]$Number, +# PowerShell Operator css properties +[psobject]$Operator = 'color: var(--cyan); font-weight: bold;', +# PowerShell Position css properties +[psobject]$Position, +# PowerShell StatementSeparator css properties +[psobject]$StatementSeparator, +# PowerShell String css properties +[psobject]$String = 'color: var(--brightCyan)', +# PowerShell Type css properties +[psobject]$Type, +# PowerShell Unknown css properties +[psobject]$Unknown, +# PowerShell Variable css properties +[psobject]$Variable = "color: var(--yellow);" +) + +# Get our metadata +$myMetadata = [Management.Automation.CommandMetadata]$MyInvocation.MyCommand +# walk over each of our parameters +foreach ($parameterName in $myMetadata.Parameters.Keys) { + # and get the parameter variable. + $var = $ExecutionContext.SessionState.PSVariable.Get($parameterName) + $css = + # If it is a string + if ($var.value -is [string]) { + $var.value # include it inline. + } + # If it is a dictionary + elseif ($var.value -is [Collections.IDictionary]) { + # Include each key/value pair + @(foreach ($key in $var.Value.keys) { + $key, $var.Value[$key] -join ':' + }) -join ';' + } + # If it is a psobject + elseif ($var.value -is [PSObject]) { + # Include each property/value + @(foreach ($property in $var.Value.psobject.properties) { + $property.Name, $var.Value.($property.Name) -join ':' + }) -join ';' + } + + # If we had a CSS value for this parameter + if ($css) { + # declare a selector with that value. + ".powershell-$($parameterName.ToLower()) { $css }" + } +} \ No newline at end of file diff --git a/_includes/HighlightScript.ps1 b/_includes/HighlightScript.ps1 new file mode 100644 index 0000000..37c5974 --- /dev/null +++ b/_includes/HighlightScript.ps1 @@ -0,0 +1,63 @@ +<# +.SYNOPSIS + Includes a highlighted script +.DESCRIPTION + Includes a highlighted script block. +.NOTES + This will apply CSS classes to each token within a script block. + + We need to also /_includes/HighlightColors to define the CSS files. +#> +[OutputType('text/html')] +param( +# The script block to highlight +[ScriptBlock] +$ScriptBlock = {} +) + +# Stringify our code +$code = "$ScriptBlock" +@( +# Create the `
` and `` blocks.
+"
"
+
+# Tokenize our scripts
+$codeTokens = [Management.Automation.PSParser]::Tokenize($code, [ref]$null)
+# and keep track of the previous token.
+$PreviousToken = $null
+# For each token
+foreach ($token in $codeTokens) {
+    # get the content
+    $content = $token.Content
+    # variables don't include the `$` or `@`
+    if ($token.Type -in 'Variable', 'String') {
+        # so fix our content for this special case.
+        $content = $code.Substring($token.Start, $token.Length)
+    }
+    # If there was a previous token
+    if ($PreviousToken) {
+        # Figure out how far it has been since our previous span,
+        $prevEnd = $PreviousToken.Start + $PreviousToken.Length
+        # get a substring,
+        $substring = $code.Substring($prevEnd, $token.Start -  $prevEnd)
+        if ($substring) {
+            # and encode it.
+            [Web.HttpUtility]::HtmlEncode($substring)
+        }
+    }
+        
+    # If we have any content
+    if ($content) {
+        # create a span of the right CSS class
+        "$(
+            # and encode the content
+            [Web.HttpUtility]::HtmlEncode($content)
+        )"
+    }
+    
+    # Update our previous token pointer
+    $PreviousToken = $token
+}
+# After we have processed all tokens,
+"
" # close our block. +) -join '' # Output our highlighted codeblock. \ No newline at end of file diff --git a/_includes/Palette.ps1 b/_includes/Palette.ps1 new file mode 100644 index 0000000..6fee1a4 --- /dev/null +++ b/_includes/Palette.ps1 @@ -0,0 +1,137 @@ +<# +.SYNOPSIS + Includes a palette selector +.DESCRIPTION + Includes a palette selector and randomized palette switcher. + + This allows the page to use multiple color palettes. +#> +param( +# The source for all palette information +[uri] +$AllPalettesSource = 'https://4bitcss.com/Palettes.json', + +# The Palette CDN. This is the root URL of all palettes. +[uri] +$PaletteCDN = 'https://cdn.jsdelivr.net/gh/2bitdesigns/4bitcss@latest/css/', + +# The identifier for the palette ` +$( + foreach ($palette in $script:AllPalettes.psobject.Properties) { + $paletteName = $palette.name + $palette = $palette.value + $selectedPalette = if ($defaultPalette -and $defaultPalette -eq $paletteName) { " selected='true'"} else { '' } + "" + } +) + + + + +"@ + +$HTML = @" + +$PaletteSelector +"@ + +$HTML \ No newline at end of file diff --git a/deploy.ps1 b/deploy.ps1 index eb9315a..5fc57bd 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -1,5 +1,4 @@ -Import-Module ./Fun.psd1 -Force -PassThru +$funModule = Import-Module ./Fun.psd1 -Force -PassThru -. ./Fun.fun.ps1 - -(fun).Build() +$funJob = . ./Fun.fun.ps1 +$funJob.Fun.Deploy() diff --git a/layout.ps1 b/layout.ps1 new file mode 100644 index 0000000..50fad1e --- /dev/null +++ b/layout.ps1 @@ -0,0 +1,264 @@ +<# +.SYNOPSIS + Layout +.DESCRIPTION + This is a layout script. + + This is a simple and helpful convention for laying out content. + + Using a layout script gives you a consistent look and feel. + + Layout scripts should accept an object pipeline of input, + and can have any number of additional parameters. + + Layout scripts are often aliased to `layout`. +.NOTES + This is a simple convention. + + Just call your layout script `layout.ps1`, and alias it to `layout`. + + A layout script should accept any pipelined input. + This is what we are laying out. + + Layout scripts may accept any number of additional parameters. + This is how layout can be customized. + + Layout scripts can be copied and pasted or as custom as you want. +#> +param( + # The title of the page. + # This defaults an existing variable `$title` + # or the current request url, replacing slashes with spaces. + [string]$Title = $( + @($Title, $page.Title, $site.Title, $env:Title, ( + $request.Url.LocalPath -replace '/', ' ' + ) -ne '')[0] + ), + + # The description of the page. + # This defaults to an existing variable `$description` + [string]$Description = $Description, + + # The time the page was published. + # This defaults to an existing `$publishedAt` + # (as long as it is a `[DateTime]`) + $PublishedAt = $( + if ($PublishedAt -as [DateTime]) { + $PublishedAt -as [DateTime] + } + ), + + # A page image. + # This defaults to an existing `$image` variable. + $Image = $Image, + + # The name of the palette to use. + [Alias('Palette')] + [string] + $PaletteName = $( + if ($page.PaletteName) { $page.PaletteName } + elseif ($site.PaletteName) { $site.PaletteName } + else { 'AdventureTime' } + ), + + # The Google Font name. + # This will be used for most elements. + [Alias('FontName')] + [string] + $Font = $( + if ($page.Font) { $page.Font } + elseif ($site.Font) { $site.Font } + elseif ($env:Font) { $env:Font } + else { 'Roboto' } + ), + + # The header font name + # This will be used for `

`,`

`, `

` elements. + [string] + $HeaderFont = $( + if ($page.HeaderFont) { $page.HeaderFont } + elseif ($site.HeaderFont) { $site.HeaderFont } + elseif ($env:HeaderFont) { $env:HeaderFont } + else { 'Nunito Sans' } + ), + + # The Google Code Font name + # This will be used for `
` elements.
+    [string]
+    $CodeFont = $(
+        if ($page.CodeFont) { $page.CodeFont }
+        elseif ($site.CodeFont) { $site.CodeFont }
+        elseif ($env:CodeFont) { $env:CodeFont }
+        else { 'Inconsolata'} 
+    ),
+
+    [uri[]]
+    $StyleSheet,
+
+    [string]
+    $AnalyticsId = $(
+        if ($page.AnalyticsId) { $page.AnalyticsId }
+        elseif ($site.AnalyticsId) { $site.AnalyticsId }
+        elseif ($env:AnalyticsId) { $env:AnalyticsId }
+        else { '' }
+    ),
+
+    # The repository being displayed.
+    # This will default an existing `$repository`,
+    # or the `$env:GH_REPOSITORY`
+    [string]
+    $Repository = $(
+        if ($Repository) {
+            $Repository
+        } elseif ($env:GH_REPOSITORY) {
+            $env:GH_REPOSITORY
+        }
+    ),
+
+    $Footer = $(
+        if ($footer) {
+            $footer
+        } else {
+            [Ordered]@{
+                "/contributing" = 'Contributing'
+                "/code_of_conduct" = "Code of Conduct"
+                "/security" = "Security"
+            }
+        }
+    ),
+
+    $Adknowledge = $(
+        if ($Adknowledge) {
+            $Adknowledge
+        } else {            
+            ""
+            # ""
+            ""                    
+        }
+    ),
+
+    [cultureinfo]
+    $Culture = 'en-us'
+)
+
+filter htmlEncode { [Web.HttpUtility]::HtmlEncode("$_") }
+filter attributEncode {[Web.HttpUtility]::HtmlAttributeEncode("$_")}
+filter urlEncode {[Web.HttpUtility]::UrlEncode("$_")}
+
+@(
+""
+""    
+    ""
+        if ($AnalyticsId) {
+            @"
+
+
+
+"@
+        }
+        # Set the viewport so that we work decently well on mobile.
+        ''
+        # Set the charset so emoji render 😉.
+        ''
+
+        # If we have a title
+        if ($Title) {
+            # set ``
+            "<title>$($title | htmlEncode)"
+            # and [OpenGraph](https://ogp.me) title.
+            ""
+        }
+
+        # If we have a description
+        if ($Description) {
+            # more info for OpenGraph
+            ""
+        }
+
+
+        if ($Image) {
+            ""
+        }
+        
+        # * Color palette
+        if ($PaletteName) {
+            ""
+        }
+        # * Google Font
+        if ($Font) {
+            ""
+        }
+        if ($HeaderFont) {
+            ""
+        }
+
+        # * Code font
+        if ($CodeFont) {
+            ""
+        }
+
+        foreach ($sheet in $StyleSheet) {
+            ""
+        }
+        
+        # Layouts often define custom styles        
+        ""
+    ""
+
+    ""
+        "
" + "
" + "" + if ($Repository) { + $Repository = $Repository -replace '^https://github.com/' -replace '.git$' + "" + } else { + "" + } + "
" + "
" + "
" + "
" + . /_includes/Palette -DefaultPalette $PaletteName + "
" + "
" + "
" + "
" + "
" + if ($args) { + "
" + $args -join "`n" + "
" + } + "
" + $input -join "`n" + "
" + "
" + "
" + "" + if ($Footer -is [Collections.IDictionary]) { + foreach ($footerItem in $Footer.GetEnumerator()) { + "$($footerItem.Value)" + } + } + "" + if ($Adknowledge) { + "
$Adknowledge
" + } + "
" + "" + . /_includes/CopyCode + "" +"" +) -join "`n"