diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..36bd853 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [StartAutomating] diff --git a/.github/workflows/BuildTurtle.yml b/.github/workflows/BuildTurtle.yml new file mode 100644 index 0000000..f63328c --- /dev/null +++ b/.github/workflows/BuildTurtle.yml @@ -0,0 +1,502 @@ + +name: Build Turtle Module +on: + push: + pull_request: + workflow_dispatch: +jobs: + TestPowerShellOnLinux: + runs-on: ubuntu-latest + steps: + - name: InstallPester + id: InstallPester + shell: pwsh + run: | + $Parameters = @{} + $Parameters.PesterMaxVersion = ${env:PesterMaxVersion} + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: InstallPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {<# + .Synopsis + Installs Pester + .Description + Installs Pester + #> + param( + # The maximum pester version. Defaults to 4.99.99. + [string] + $PesterMaxVersion = '4.99.99' + ) + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Install-Module -Name Pester -Repository PSGallery -Force -Scope CurrentUser -MaximumVersion $PesterMaxVersion -SkipPublisherCheck -AllowClobber + Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion} @Parameters + - name: Check out repository + uses: actions/checkout@v4 + - name: RunPester + id: RunPester + shell: pwsh + 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)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: RunPester $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {<# + .Synopsis + Runs Pester + .Description + Runs Pester tests after importing a PowerShell module + #> + param( + # 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] + $NoCoverage + ) + + $global:ErrorActionPreference = 'continue' + $global:ProgressPreference = 'silentlycontinue' + + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + if (-not $ModulePath) { $ModulePath = ".\$moduleName.psd1" } + $importedPester = Import-Module Pester -Force -PassThru -MaximumVersion $PesterMaxVersion + $importedModule = Import-Module $ModulePath -Force -PassThru + $importedPester, $importedModule | Out-Host + + $codeCoverageParameters = @{ + CodeCoverage = "$($importedModule | Split-Path)\*-*.ps1" + CodeCoverageOutputFile = ".\$moduleName.Coverage.xml" + } + + if ($NoCoverage) { + $codeCoverageParameters = @{} + } + + + $result = + Invoke-Pester -PassThru -Verbose -OutputFile ".\$moduleName.TestResults.xml" -OutputFormat NUnitXml @codeCoverageParameters + + if ($result.FailedCount -gt 0) { + "::debug:: $($result.FailedCount) tests failed" + foreach ($r in $result.TestResult) { + if (-not $r.Passed) { + "::error::$($r.describe, $r.context, $r.name -join ' ') $($r.FailureMessage)" + } + } + throw "::error:: $($result.FailedCount) tests failed" + } + } @Parameters + - name: PublishTestResults + uses: actions/upload-artifact@main + with: + name: PesterResults + path: '**.TestResults.xml' + if: ${{always()}} + TagReleaseAndPublish: + runs-on: ubuntu-latest + if: ${{ success() }} + steps: + - name: Check out repository + uses: actions/checkout@v2 + - name: TagModuleVersion + id: TagModuleVersion + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.UserEmail = ${env:UserEmail} + $Parameters.UserName = ${env:UserName} + $Parameters.TagVersionFormat = ${env:TagVersionFormat} + $Parameters.TagAnnotationFormat = ${env:TagAnnotationFormat} + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: TagModuleVersion $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {param( + [string] + $ModulePath, + + # The user email associated with a git commit. + [string] + $UserEmail, + + # The user name associated with a git commit. + [string] + $UserName, + + # The tag version format (default value: 'v$(imported.Version)') + # This can expand variables. $imported will contain the imported module. + [string] + $TagVersionFormat = 'v$($imported.Version)', + + # The tag version format (default value: '$($imported.Name) $(imported.Version)') + # This can expand variables. $imported will contain the imported module. + [string] + $TagAnnotationFormat = '$($imported.Name) $($imported.Version)' + ) + + + $gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json + } else { $null } + + + @" + ::group::GitHubEvent + $($gitHubEvent | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping Tagging" | Out-Host + return + } + + + + $imported = + if (-not $ModulePath) { + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + Import-Module ".\$moduleName.psd1" -Force -PassThru -Global + } else { + Import-Module $modulePath -Force -PassThru -Global + } + + if (-not $imported) { return } + + $targetVersion =$ExecutionContext.InvokeCommand.ExpandString($TagVersionFormat) + $existingTags = git tag --list + + @" + Target Version: $targetVersion + + Existing Tags: + $($existingTags -join [Environment]::NewLine) + "@ | Out-Host + + $versionTagExists = $existingTags | Where-Object { $_ -match $targetVersion } + + if ($versionTagExists) { + "::warning::Version $($versionTagExists)" + return + } + + if (-not $UserName) { $UserName = $env:GITHUB_ACTOR } + if (-not $UserEmail) { $UserEmail = "$UserName@github.com" } + git config --global user.email $UserEmail + git config --global user.name $UserName + + git tag -a $targetVersion -m $ExecutionContext.InvokeCommand.ExpandString($TagAnnotationFormat) + git push origin --tags + + if ($env:GITHUB_ACTOR) { + exit 0 + }} @Parameters + - name: ReleaseModule + id: ReleaseModule + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.UserEmail = ${env:UserEmail} + $Parameters.UserName = ${env:UserName} + $Parameters.TagVersionFormat = ${env:TagVersionFormat} + $Parameters.ReleaseNameFormat = ${env:ReleaseNameFormat} + $Parameters.ReleaseAsset = ${env:ReleaseAsset} + $Parameters.ReleaseAsset = $parameters.ReleaseAsset -split ';' -replace '^[''"]' -replace '[''"]$' + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: ReleaseModule $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {param( + [string] + $ModulePath, + + # The user email associated with a git commit. + [string] + $UserEmail, + + # The user name associated with a git commit. + [string] + $UserName, + + # The tag version format (default value: 'v$(imported.Version)') + # This can expand variables. $imported will contain the imported module. + [string] + $TagVersionFormat = 'v$($imported.Version)', + + # The release name format (default value: '$($imported.Name) $($imported.Version)') + [string] + $ReleaseNameFormat = '$($imported.Name) $($imported.Version)', + + # Any assets to attach to the release. Can be a wildcard or file name. + [string[]] + $ReleaseAsset + ) + + + $gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json + } else { $null } + + + @" + ::group::GitHubEvent + $($gitHubEvent | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping GitHub release" | Out-Host + return + } + + + + $imported = + if (-not $ModulePath) { + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + Import-Module ".\$moduleName.psd1" -Force -PassThru -Global + } else { + Import-Module $modulePath -Force -PassThru -Global + } + + if (-not $imported) { return } + + $targetVersion =$ExecutionContext.InvokeCommand.ExpandString($TagVersionFormat) + $targetReleaseName = $targetVersion + $releasesURL = 'https://api.github.com/repos/${{github.repository}}/releases' + "Release URL: $releasesURL" | Out-Host + $listOfReleases = Invoke-RestMethod -Uri $releasesURL -Method Get -Headers @{ + "Accept" = "application/vnd.github.v3+json" + "Authorization" = 'Bearer ${{ secrets.GITHUB_TOKEN }}' + } + + $releaseExists = $listOfReleases | Where-Object tag_name -eq $targetVersion + + if ($releaseExists) { + "::warning::Release '$($releaseExists.Name )' Already Exists" | Out-Host + $releasedIt = $releaseExists + } else { + $releasedIt = Invoke-RestMethod -Uri $releasesURL -Method Post -Body ( + [Ordered]@{ + owner = '${{github.owner}}' + repo = '${{github.repository}}' + tag_name = $targetVersion + name = $ExecutionContext.InvokeCommand.ExpandString($ReleaseNameFormat) + body = + if ($env:RELEASENOTES) { + $env:RELEASENOTES + } elseif ($imported.PrivateData.PSData.ReleaseNotes) { + $imported.PrivateData.PSData.ReleaseNotes + } else { + "$($imported.Name) $targetVersion" + } + draft = if ($env:RELEASEISDRAFT) { [bool]::Parse($env:RELEASEISDRAFT) } else { $false } + prerelease = if ($env:PRERELEASE) { [bool]::Parse($env:PRERELEASE) } else { $false } + } | ConvertTo-Json + ) -Headers @{ + "Accept" = "application/vnd.github.v3+json" + "Content-type" = "application/json" + "Authorization" = 'Bearer ${{ secrets.GITHUB_TOKEN }}' + } + } + + + + + + if (-not $releasedIt) { + throw "Release failed" + } else { + $releasedIt | Out-Host + } + + $releaseUploadUrl = $releasedIt.upload_url -replace '\{.+$' + + if ($ReleaseAsset) { + $fileList = Get-ChildItem -Recurse + $filesToRelease = + @(:nextFile foreach ($file in $fileList) { + foreach ($relAsset in $ReleaseAsset) { + if ($relAsset -match '[\*\?]') { + if ($file.Name -like $relAsset) { + $file; continue nextFile + } + } elseif ($file.Name -eq $relAsset -or $file.FullName -eq $relAsset) { + $file; continue nextFile + } + } + }) + + $releasedFiles = @{} + foreach ($file in $filesToRelease) { + if ($releasedFiles[$file.Name]) { + Write-Warning "Already attached file $($file.Name)" + continue + } else { + $fileBytes = [IO.File]::ReadAllBytes($file.FullName) + $releasedFiles[$file.Name] = + Invoke-RestMethod -Uri "${releaseUploadUrl}?name=$($file.Name)" -Headers @{ + "Accept" = "application/vnd.github+json" + "Authorization" = 'Bearer ${{ secrets.GITHUB_TOKEN }}' + } -Body $fileBytes -ContentType Application/octet-stream + $releasedFiles[$file.Name] + } + } + + "Attached $($releasedFiles.Count) file(s) to release" | Out-Host + } + + + + } @Parameters + - name: PublishPowerShellGallery + id: PublishPowerShellGallery + shell: pwsh + run: | + $Parameters = @{} + $Parameters.ModulePath = ${env:ModulePath} + $Parameters.Exclude = ${env:Exclude} + $Parameters.Exclude = $parameters.Exclude -split ';' -replace '^[''"]' -replace '[''"]$' + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: PublishPowerShellGallery $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {param( + [string] + $ModulePath, + + [string[]] + $Exclude = @('*.png', '*.mp4', '*.jpg','*.jpeg', '*.gif', 'docs[/\]*') + ) + + $gitHubEvent = if ($env:GITHUB_EVENT_PATH) { + [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) | ConvertFrom-Json + } else { $null } + + if (-not $Exclude) { + $Exclude = @('*.png', '*.mp4', '*.jpg','*.jpeg', '*.gif','docs[/\]*') + } + + + @" + ::group::GitHubEvent + $($gitHubEvent | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + @" + ::group::PSBoundParameters + $($PSBoundParameters | ConvertTo-Json -Depth 100) + ::endgroup:: + "@ | Out-Host + + if (-not ($gitHubEvent.head_commit.message -match "Merge Pull Request #(?\d+)") -and + (-not $gitHubEvent.psobject.properties['inputs'])) { + "::warning::Pull Request has not merged, skipping Gallery Publish" | Out-Host + return + } + + + $imported = + if (-not $ModulePath) { + $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/" + Import-Module ".\$moduleName.psd1" -Force -PassThru -Global + } else { + Import-Module $modulePath -Force -PassThru -Global + } + + if (-not $imported) { return } + + $foundModule = try { Find-Module -Name $imported.Name -ErrorAction SilentlyContinue} catch {} + + if ($foundModule -and (([Version]$foundModule.Version) -ge ([Version]$imported.Version))) { + "::warning::Gallery Version of $moduleName is more recent ($($foundModule.Version) >= $($imported.Version))" | Out-Host + } else { + + $gk = '${{secrets.GALLERYKEY}}' + + $rn = Get-Random + $moduleTempFolder = Join-Path $pwd "$rn" + $moduleTempPath = Join-Path $moduleTempFolder $moduleName + New-Item -ItemType Directory -Path $moduleTempPath -Force | Out-Host + + Write-Host "Staging Directory: $ModuleTempPath" + + $imported | Split-Path | + Get-ChildItem -Force | + Where-Object Name -NE $rn | + Copy-Item -Destination $moduleTempPath -Recurse + + $moduleGitPath = Join-Path $moduleTempPath '.git' + Write-Host "Removing .git directory" + if (Test-Path $moduleGitPath) { + Remove-Item -Recurse -Force $moduleGitPath + } + + if ($Exclude) { + "::notice::Attempting to Exlcude $exclude" | Out-Host + Get-ChildItem $moduleTempPath -Recurse | + Where-Object { + foreach ($ex in $exclude) { + if ($_.FullName -like $ex) { + "::notice::Excluding $($_.FullName)" | Out-Host + return $true + } + } + } | + Remove-Item + } + + Write-Host "Module Files:" + Get-ChildItem $moduleTempPath -Recurse + Write-Host "Publishing $moduleName [$($imported.Version)] to Gallery" + Publish-Module -Path $moduleTempPath -NuGetApiKey $gk + if ($?) { + Write-Host "Published to Gallery" + } else { + Write-Host "Gallery Publish Failed" + exit 1 + } + } + } @Parameters + BuildTurtle: + runs-on: ubuntu-latest + if: ${{ success() }} + steps: + - name: Check out repository + uses: actions/checkout@main + - name: UseEZOut + uses: StartAutomating/EZOut@master + - name: Run Turtle (on branch) + if: ${{github.ref_name != 'main'}} + uses: ./ + id: TurtleAction +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} diff --git a/Build/GitHub/Actions/TurtleAction.ps1 b/Build/GitHub/Actions/TurtleAction.ps1 new file mode 100644 index 0000000..ba96b21 --- /dev/null +++ b/Build/GitHub/Actions/TurtleAction.ps1 @@ -0,0 +1,351 @@ +<# +.Synopsis + GitHub Action for Turtle +.Description + GitHub Action for Turtle. This will: + + * Import Turtle + * If `-Run` is provided, run that script + * Otherwise, unless `-SkipScriptFile` is passed, run all *.Turtle.ps1 files beneath the workflow directory + * If any `-ActionScript` was provided, run scripts from the action path that match a wildcard pattern. + + If you will be making changes using the GitHubAPI, you should provide a -GitHubToken + If none is provided, and ENV:GITHUB_TOKEN is set, this will be used instead. + Any files changed can be outputted by the script, and those changes can be checked back into the repo. + Make sure to use the "persistCredentials" option with checkout. +#> + +param( +# A PowerShell Script that uses Turtle. +# Any files outputted from the script will be added to the repository. +# If those files have a .Message attached to them, they will be committed with that message. +[string] +$Run, + +# If set, will not process any files named *.Turtle.ps1 +[switch] +$SkipScriptFile, + +# A list of modules to be installed from the PowerShell gallery before scripts run. +[string[]] +$InstallModule, + +# If provided, will commit any remaining changes made to the workspace with this commit message. +[string] +$CommitMessage, + +# If provided, will checkout a new branch before making the changes. +# If not provided, will use the current branch. +[string] +$TargetBranch, + +# The name of one or more scripts to run, from this action's path. +[string[]] +$ActionScript, + +# The github token to use for requests. +[string] +$GitHubToken = '{{ secrets.GITHUB_TOKEN }}', + +# The user email associated with a git commit. If this is not provided, it will be set to the username@noreply.github.com. +[string] +$UserEmail, + +# The user name associated with a git commit. +[string] +$UserName, + +# If set, will not push any changes made to the repository. +# (they will still be committed unless `-NoCommit` is passed) +[switch] +$NoPush, + +# If set, will not commit any changes made to the repository. +# (this also implies `-NoPush`) +[switch] +$NoCommit +) + +$ErrorActionPreference = 'continue' +"::group::Parameters" | Out-Host +[PSCustomObject]$PSBoundParameters | Format-List | Out-Host +"::endgroup::" | Out-Host + +$gitHubEventJson = [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) +$gitHubEvent = + if ($env:GITHUB_EVENT_PATH) { + $gitHubEventJson | ConvertFrom-Json + } else { $null } +"::group::Parameters" | Out-Host +$gitHubEvent | Format-List | Out-Host +"::endgroup::" | Out-Host + + +$anyFilesChanged = $false +$ActionModuleName = 'Turtle' +$actorInfo = $null + + +$checkDetached = git symbolic-ref -q HEAD +if ($LASTEXITCODE) { + "::warning::On detached head, skipping action" | Out-Host + exit 0 +} + +function InstallActionModule { + param([string]$ModuleToInstall) + $moduleInWorkspace = Get-ChildItem -Path $env:GITHUB_WORKSPACE -Recurse -File | + Where-Object Name -eq "$($moduleToInstall).psd1" | + Where-Object { + $(Get-Content $_.FullName -Raw) -match 'ModuleVersion' + } + if (-not $moduleInWorkspace) { + $availableModules = Get-Module -ListAvailable + if ($availableModules.Name -notcontains $moduleToInstall) { + Install-Module $moduleToInstall -Scope CurrentUser -Force -AcceptLicense -AllowClobber + } + Import-Module $moduleToInstall -Force -PassThru | Out-Host + } else { + Import-Module $moduleInWorkspace.FullName -Force -PassThru | Out-Host + } +} +function ImportActionModule { + #region -InstallModule + if ($InstallModule) { + "::group::Installing Modules" | Out-Host + foreach ($moduleToInstall in $InstallModule) { + InstallActionModule -ModuleToInstall $moduleToInstall + } + "::endgroup::" | Out-Host + } + #endregion -InstallModule + + if ($env:GITHUB_ACTION_PATH) { + $LocalModulePath = Join-Path $env:GITHUB_ACTION_PATH "$ActionModuleName.psd1" + if (Test-path $LocalModulePath) { + Import-Module $LocalModulePath -Force -PassThru | Out-String + } else { + throw "Module '$ActionModuleName' not found" + } + } elseif (-not (Get-Module $ActionModuleName)) { + throw "Module '$ActionModuleName' not found" + } + + "::notice title=ModuleLoaded::$ActionModuleName Loaded from Path - $($LocalModulePath)" | Out-Host + if ($env:GITHUB_STEP_SUMMARY) { + "# $($ActionModuleName)" | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } +} +function InitializeAction { + #region Custom + #endregion Custom + + # Configure git based on the $env:GITHUB_ACTOR + if (-not $UserName) { $UserName = $env:GITHUB_ACTOR } + if (-not $actorID) { $actorID = $env:GITHUB_ACTOR_ID } + $actorInfo = + if ($GitHubToken -notmatch '^\{{2}' -and $GitHubToken -notmatch '\}{2}$') { + Invoke-RestMethod -Uri "https://api.github.com/user/$actorID" -Headers @{ Authorization = "token $GitHubToken" } + } else { + Invoke-RestMethod -Uri "https://api.github.com/user/$actorID" + } + + if (-not $UserEmail) { $UserEmail = "$UserName@noreply.github.com" } + git config --global user.email $UserEmail + git config --global user.name $actorInfo.name + + # Pull down any changes + git pull | Out-Host + + if ($TargetBranch) { + "::notice title=Expanding target branch string $targetBranch" | Out-Host + $TargetBranch = $ExecutionContext.SessionState.InvokeCommand.ExpandString($TargetBranch) + "::notice title=Checking out target branch::$targetBranch" | Out-Host + git checkout -b $TargetBranch | Out-Host + git pull | Out-Host + } +} + +function InvokeActionModule { + $myScriptStart = [DateTime]::Now + $myScript = $ExecutionContext.SessionState.PSVariable.Get("Run").Value + if ($myScript) { + Invoke-Expression -Command $myScript | + . ProcessOutput | + Out-Host + return + } + $myScriptTook = [Datetime]::Now - $myScriptStart + $MyScriptFilesStart = [DateTime]::Now + + $myScriptList = @() + $shouldSkip = $ExecutionContext.SessionState.PSVariable.Get("SkipScriptFile").Value + if ($shouldSkip) { + return + } + $scriptFiles = @( + Get-ChildItem -Recurse -Path $env:GITHUB_WORKSPACE | + Where-Object Name -Match "\.$($ActionModuleName)\.ps1$" + if ($ActionScript) { + if ($ActionScript -match '^\s{0,}/' -and $ActionScript -match '/\s{0,}$') { + $ActionScriptPattern = $ActionScript.Trim('/').Trim() -as [regex] + if ($ActionScriptPattern) { + $ActionScriptPattern = [regex]::new($ActionScript.Trim('/').Trim(), 'IgnoreCase,IgnorePatternWhitespace', [timespan]::FromSeconds(0.5)) + Get-ChildItem -Recurse -Path $env:GITHUB_ACTION_PATH | + Where-Object { $_.Name -Match "\.$($ActionModuleName)\.ps1$" -and $_.FullName -match $ActionScriptPattern } + } + } else { + Get-ChildItem -Recurse -Path $env:GITHUB_ACTION_PATH | + Where-Object Name -Match "\.$($ActionModuleName)\.ps1$" | + Where-Object FullName -Like $ActionScript + } + } + ) | Select-Object -Unique + $scriptFiles | + ForEach-Object -Begin { + if ($env:GITHUB_STEP_SUMMARY) { + "## $ActionModuleName Scripts" | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + } -Process { + $myScriptList += $_.FullName.Replace($env:GITHUB_WORKSPACE, '').TrimStart('/') + $myScriptCount++ + $scriptFile = $_ + if ($env:GITHUB_STEP_SUMMARY) { + "### $($scriptFile.Fullname -replace [Regex]::Escape($env:GITHUB_WORKSPACE))" | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + $scriptCmd = $ExecutionContext.SessionState.InvokeCommand.GetCommand($scriptFile.FullName, 'ExternalScript') + foreach ($requiredModule in $CommandInfo.ScriptBlock.Ast.ScriptRequirements.RequiredModules) { + if ($requiredModule.Name -and + (-not $requiredModule.MaximumVersion) -and + (-not $requiredModule.RequiredVersion) + ) { + InstallActionModule $requiredModule.Name + } + } + Push-Location $scriptFile.Directory.Fullname + $scriptFileOutputs = . $scriptCmd + $scriptFileOutputs | + . ProcessOutput | + Out-Host + Pop-Location + } + + $MyScriptFilesTook = [Datetime]::Now - $MyScriptFilesStart + $SummaryOfMyScripts = "$myScriptCount $ActionModuleName scripts took $($MyScriptFilesTook.TotalSeconds) seconds" + $SummaryOfMyScripts | + Out-Host + if ($env:GITHUB_STEP_SUMMARY) { + $SummaryOfMyScripts | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + #region Custom + #endregion Custom +} + +function OutError { + $anyRuntimeExceptions = $false + foreach ($err in $error) { + $errParts = @( + "::error " + @( + if ($err.InvocationInfo.ScriptName) { + "file=$($err.InvocationInfo.ScriptName)" + } + if ($err.InvocationInfo.ScriptLineNumber -ge 1) { + "line=$($err.InvocationInfo.ScriptLineNumber)" + if ($err.InvocationInfo.OffsetInLine -ge 1) { + "col=$($err.InvocationInfo.OffsetInLine)" + } + } + if ($err.CategoryInfo.Activity) { + "title=$($err.CategoryInfo.Activity)" + } + ) -join ',' + "::" + $err.Exception.Message + if ($err.CategoryInfo.Category -eq 'OperationStopped' -and + $err.CategoryInfo.Reason -eq 'RuntimeException') { + $anyRuntimeExceptions = $true + } + ) -join '' + $errParts | Out-Host + if ($anyRuntimeExceptions) { + exit 1 + } + } +} + +function PushActionOutput { + if ($anyFilesChanged) { + "::notice::$($anyFilesChanged) Files Changed" | Out-Host + } + if ($CommitMessage -or $anyFilesChanged) { + if ($CommitMessage) { + Get-ChildItem $env:GITHUB_WORKSPACE -Recurse | + ForEach-Object { + $gitStatusOutput = git status $_.Fullname -s + if ($gitStatusOutput) { + git add $_.Fullname + } + } + + git commit -m $ExecutionContext.SessionState.InvokeCommand.ExpandString($CommitMessage) + } + + $checkDetached = git symbolic-ref -q HEAD + if (-not $LASTEXITCODE -and -not $NoPush -and -not $noCommit) { + if ($TargetBranch -and $anyFilesChanged) { + "::notice::Pushing Changes to $targetBranch" | Out-Host + git push --set-upstream origin $TargetBranch + } elseif ($anyFilesChanged) { + "::notice::Pushing Changes" | Out-Host + git push + } + "Git Push Output: $($gitPushed | Out-String)" + } else { + "::notice::Not pushing changes (on detached head)" | Out-Host + $LASTEXITCODE = 0 + exit 0 + } + } +} + +filter ProcessOutput { + $out = $_ + $outItem = Get-Item -Path $out -ErrorAction Ignore + if (-not $outItem -and $out -is [string]) { + $out | Out-Host + if ($env:GITHUB_STEP_SUMMARY) { + "> $out" | Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + return + } + $fullName, $shouldCommit = + if ($out -is [IO.FileInfo]) { + $out.FullName, (git status $out.Fullname -s) + } elseif ($outItem) { + $outItem.FullName, (git status $outItem.Fullname -s) + } + if ($shouldCommit -and -not $NoCommit) { + "$fullName has changed, and should be committed" | Out-Host + git add $fullName + if ($out.Message) { + git commit -m "$($out.Message)" | Out-Host + } elseif ($out.CommitMessage) { + git commit -m "$($out.CommitMessage)" | Out-Host + } elseif ($gitHubEvent.head_commit.message) { + git commit -m "$($gitHubEvent.head_commit.message)" | Out-Host + } + $anyFilesChanged = $true + } + $out +} + +. ImportActionModule +. InitializeAction +. InvokeActionModule +. PushActionOutput +. OutError \ No newline at end of file diff --git a/Build/GitHub/Jobs/BuildTurtle.psd1 b/Build/GitHub/Jobs/BuildTurtle.psd1 new file mode 100644 index 0000000..d091841 --- /dev/null +++ b/Build/GitHub/Jobs/BuildTurtle.psd1 @@ -0,0 +1,18 @@ +@{ + "runs-on" = "ubuntu-latest" + if = '${{ success() }}' + steps = @( + @{ + name = 'Check out repository' + uses = 'actions/checkout@main' + }, + 'RunEZOut' # , + @{ + name = 'Run Turtle (on branch)' + if = '${{github.ref_name != ''main''}}' + uses = './' + id = 'TurtleAction' + } + # 'BuildAndPublishContainer' + ) +} \ No newline at end of file diff --git a/Build/GitHub/Steps/PublishTestResults.psd1 b/Build/GitHub/Steps/PublishTestResults.psd1 new file mode 100644 index 0000000..e8111e8 --- /dev/null +++ b/Build/GitHub/Steps/PublishTestResults.psd1 @@ -0,0 +1,10 @@ +@{ + name = 'PublishTestResults' + uses = 'actions/upload-artifact@main' + with = @{ + name = 'PesterResults' + path = '**.TestResults.xml' + } + if = '${{always()}}' +} + diff --git a/Build/Turtle.GitHubAction.PSDevOps.ps1 b/Build/Turtle.GitHubAction.PSDevOps.ps1 new file mode 100644 index 0000000..352cb31 --- /dev/null +++ b/Build/Turtle.GitHubAction.PSDevOps.ps1 @@ -0,0 +1,10 @@ +#requires -Module PSDevOps +Import-BuildStep -SourcePath ( + Join-Path $PSScriptRoot 'GitHub' +) -BuildSystem GitHubAction + +$PSScriptRoot | Split-Path | Push-Location + +New-GitHubAction -Name "TurtlePower" -Description 'Turtles in a PowerShell' -Action TurtleAction -Icon chevron-right -OutputPath .\action.yml + +Pop-Location \ No newline at end of file diff --git a/Build/Turtle.GitHubWorkflow.PSDevOps.ps1 b/Build/Turtle.GitHubWorkflow.PSDevOps.ps1 new file mode 100644 index 0000000..0e91289 --- /dev/null +++ b/Build/Turtle.GitHubWorkflow.PSDevOps.ps1 @@ -0,0 +1,15 @@ +#requires -Module PSDevOps +Import-BuildStep -SourcePath ( + Join-Path $PSScriptRoot 'GitHub' +) -BuildSystem GitHubWorkflow + +Push-Location ($PSScriptRoot | Split-Path) +New-GitHubWorkflow -Name "Build Turtle Module" -On Push, + PullRequest, + Demand -Job TestPowerShellOnLinux, + TagReleaseAndPublish, BuildTurtle -Environment ([Ordered]@{ + REGISTRY = 'ghcr.io' + IMAGE_NAME = '${{ github.repository }}' + }) -OutputPath .\.github\workflows\BuildTurtle.yml + +Pop-Location \ No newline at end of file diff --git a/Build/Turtle.ezout.ps1 b/Build/Turtle.ezout.ps1 new file mode 100644 index 0000000..03614f1 --- /dev/null +++ b/Build/Turtle.ezout.ps1 @@ -0,0 +1,39 @@ +#requires -Module EZOut +# Install-Module EZOut or https://github.com/StartAutomating/EZOut +$myFile = $MyInvocation.MyCommand.ScriptBlock.File +$myModuleName = 'Turtle' +$myRoot = $myFile | Split-Path | Split-Path +Push-Location $myRoot +$formatting = @( + # Add your own Write-FormatView here, + # or put them in a Formatting or Views directory + foreach ($potentialDirectory in 'Formatting','Views','Types') { + Join-Path $myRoot $potentialDirectory | + Get-ChildItem -ea ignore | + Import-FormatView -FilePath {$_.Fullname} + } +) + +$destinationRoot = $myRoot + +if ($formatting) { + $myFormatFilePath = Join-Path $destinationRoot "$myModuleName.format.ps1xml" + # You can also output to multiple paths by passing a hashtable to -OutputPath. + $formatting | Out-FormatData -Module $MyModuleName -OutputPath $myFormatFilePath +} + +$types = @( + # Add your own Write-TypeView statements here + # or declare them in the 'Types' directory + Join-Path $myRoot Types | + Get-Item -ea ignore | + Import-TypeView + +) + +if ($types) { + $myTypesFilePath = Join-Path $destinationRoot "$myModuleName.types.ps1xml" + # You can also output to multiple paths by passing a hashtable to -OutputPath. + $types | Out-TypeData -OutputPath $myTypesFilePath +} +Pop-Location diff --git a/CNAME b/CNAME new file mode 100644 index 0000000..e1bda9f --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +turtle.powershellweb.com diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..a132093 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,9 @@ +# Code of Conduct + +We have a simple subjective code of conduct: + +1. Be Respectful +2. Be Helpful +3. Do No Harm + +Failure to follow the code of conduct may result in blocks or banishment. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2c988a4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,11 @@ +# Contibuting + +We welcome suggestions and careful contributions. + +To suggest something, please [open an issue](https://github.com/PowerShellWeb/Turtle/issues) or start a [discussion](https://github.com/PowerShellWeb/Turtle/discussion) + +To add a feature, please open an issue and create a pull request. + +## Contributing Examples + +Examples are more than welcome! To contribute an example, please open an issue describing your example and create a pull request. diff --git a/Commands/Get-Turtle.ps1 b/Commands/Get-Turtle.ps1 new file mode 100644 index 0000000..1f5bb07 --- /dev/null +++ b/Commands/Get-Turtle.ps1 @@ -0,0 +1,110 @@ +function Get-Turtle { + <# + .EXAMPLE + turtle square 50 + .EXAMPLE + turtle circle 10 + .EXAMPLE + turtle polygon 10 6 + .EXAMPLE + turtle ('forward', 10, 'rotate', 120 * 3) + #> + [CmdletBinding(PositionalBinding=$false)] + [Alias('turtle')] + param( + # The arguments to pass to turtle. + [ArgumentCompleter({ + param ( $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters ) + if (-not $script:TurtleTypeData) { + $script:TurtleTypeData = Get-TypeData -TypeName Turtle + } + $methodNames = @(foreach ($memberName in $script:TurtleTypeData.Members.Keys) { + if ($script:TurtleTypeData.Members[$memberName] -is + [Management.Automation.Runspaces.ScriptMethodData]) { + $memberName + } + }) + + if ($wordToComplete) { + return $methodNames -like "$wordToComplete*" + } else { + return $methodNames + } + })] + [Parameter(ValueFromRemainingArguments)] + [PSObject[]] + $ArgumentList, + + # Any input object to process. + # If this is already a turtle object, the arguments will be applied to this object. + # If the input object is not a turtle object, it will be ignored and a new turtle object will be created. + [Parameter(ValueFromPipeline)] + [PSObject] + $InputObject + ) + + begin { + $turtleType = Get-TypeData -TypeName Turtle + $memberNames = @(foreach ($memberName in $turtleType.Members.Keys) { + if ( + ($turtleType.Members[$memberName] -is [Management.Automation.Runspaces.ScriptMethodData]) -or + ( + $turtleType.Members[$memberName] -is + [Management.Automation.Runspaces.AliasPropertyData] -and + $turtleType.Members[ + $turtleType.Members[$memberName].ReferencedMemberName + ] -is [Management.Automation.Runspaces.ScriptMethodData] + ) + ) { + $memberName + } + }) + + $memberNames = $memberNames | Sort-Object @{Expression={ $_.Length };Descending=$true}, Name + $currentTurtle = [PSCustomObject]@{PSTypeName='Turtle'} + } + + process { + + if ($PSBoundParameters.InputObject -and + $PSBoundParameters.InputObject.pstypenames -eq 'Turtle') { + $currentTurtle = $PSBoundParameters.InputObject + } + + $currentMethod = $null + + $wordsAndArguments = foreach ($arg in $ArgumentList) { + if ($arg -is [string]) { + $arg -split '\s{1,}' + } else { + $arg + } + } + + :findCommand for ($argIndex =0; $argIndex -lt $wordsAndArguments.Length; $argIndex++) { + $arg = $wordsAndArguments[$argIndex] + if ($arg -in $memberNames) { + $currentMethod = $arg + for ( + $methodArgIndex = $argIndex + 1; + $methodArgIndex -lt $wordsAndArguments.Length -and + $wordsAndArguments[$methodArgIndex] -notin $memberNames; + $methodArgIndex++) { + } + # Command without parameters + if ($methodArgIndex -eq $argIndex) { + $argList = @() + $currentTurtle = $currentTurtle.$currentMethod.Invoke() + } + else { + $argList = $wordsAndArguments[($argIndex + 1)..($methodArgIndex - 1)] + $currentTurtle = $currentTurtle.$currentMethod.Invoke($argList) + # "$($currentMethod) $($argList -join ' ')" + $argIndex = $methodArgIndex - 1 + } + } + } + + return $currentTurtle + } +} diff --git a/Commands/Move-Turtle.ps1 b/Commands/Move-Turtle.ps1 new file mode 100644 index 0000000..f7464fc --- /dev/null +++ b/Commands/Move-Turtle.ps1 @@ -0,0 +1,70 @@ +function Move-Turtle { + <# + .SYNOPSIS + Moves a turtle. + .DESCRIPTION + Moves a turtle by invoking a method with any number of arguments. + .EXAMPLE + New-Turtle | + Move-Turtle Forward 10 | + Move-Turtle Right 90 | + Move-Turtle Forward 10 | + Move-Turtle Right 90 | + Move-Turtle Forward 10 | + Move-Turtle Right 90 | + Move-Turtle Forward 10 | + Move-Turtle Right 90 | + Save-Turtle "./Square.svg" + #> + [CmdletBinding(PositionalBinding=$false)] + param( + # The method used to move the turtle. + # Any method on the turtle can be called this way. + [Parameter(Position=1,ValueFromPipelineByPropertyName)] + [ArgumentCompleter({ + param ( $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters ) + if (-not $script:TurtleTypeData) { + $script:TurtleTypeData = Get-TypeData -TypeName Turtle + } + $methodNames = @(foreach ($memberName in $script:TurtleTypeData.Members.Keys) { + if ($script:TurtleTypeData.Members[$memberName] -is + [Management.Automation.Runspaces.ScriptMethodData]) { + $memberName + } + }) + + if ($wordToComplete) { + return $methodNames -like "$wordToComplete*" + } else { + return $methodNames + } + })] + [string] + $Method = 'Forward', + + # The arguments to pass to the method. + [Parameter(ValueFromRemainingArguments,ValueFromPipelineByPropertyName)] + [PSObject[]] + $ArgumentList = 1, + + # The turtle input object. + # If not provided, a new turtle will be created. + [Parameter(ValueFromPipeline)] + [PSObject] + $InputObject + ) + + process { + if (-not $PSBoundParameters.InputObject) { + $InputObject = $PSBoundParameters['InputObject'] = [PSCustomObject]@{PSTypeName='Turtle'} + } + + $inputMethod = $inputObject.psobject.Methods[$method] + if (-not $inputMethod) { + Write-Error "Method '$method' not found on Turtle object." + return + } + + $inputMethod.Invoke($ArgumentList) + } +} diff --git a/Commands/New-Turtle.ps1 b/Commands/New-Turtle.ps1 new file mode 100644 index 0000000..76a162a --- /dev/null +++ b/Commands/New-Turtle.ps1 @@ -0,0 +1,14 @@ +function New-Turtle +{ + <# + .SYNOPSIS + Creates a new turtle object. + .DESCRIPTION + This function initializes a new turtle object with default properties. + .EXAMPLE + $turtle = New-Turtle + $turtle.Square(100).Pattern.Save("$pwd/SquarePattern.svg") + #> + param() + [PSCustomObject]@{PSTypeName='Turtle'} +} diff --git a/Commands/Save-Turtle.ps1 b/Commands/Save-Turtle.ps1 new file mode 100644 index 0000000..c8e3523 --- /dev/null +++ b/Commands/Save-Turtle.ps1 @@ -0,0 +1,73 @@ +function Save-Turtle { + <# + .SYNOPSIS + Saves a turtle. + .DESCRIPTION + Saves a turtle graphics pattern to a file. + .EXAMPLE + New-Turtle | + Move-Turtle SierpinskiTriangle 20 3 | + Save-Turtle "./SierpinskiTriangle.svg" + .EXAMPLE + Move-Turtle BoxFractal 15 5 | + Set-Turtle Stroke '#4488ff' | + Save-Turtle ./BoxFractal.png + #> + param( + # The file path to save the turtle graphics pattern. + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('Path')] + [string] + $FilePath, + + # The property of the turtle to save. + [ArgumentCompleter({ + param ( $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters ) + $turtleType = Get-TypeData -TypeName Turtle + $propertyNames = @(foreach ($memberName in $turtleType.Members.Keys) { + if ($turtleType.Members[$memberName] -is [System.Management.Automation.Runspaces.ScriptPropertyData]) { + $memberName + } + }) + + if ($wordToComplete) { + return $propertyNames -like "$wordToComplete*" + } else { + return $propertyNames + } + })] + [string] + $Property = 'Symbol', + + # The turtle input object. + [Parameter(ValueFromPipeline)] + [Alias('Turtle')] + [PSObject] + $InputObject + ) + + process { + if (-not $inputObject) { return } + switch -regex ($FilePath) { + '\.png$' { if ($Property -eq 'Symbol') { $Property = 'PNG' } } + '\.jpe?g$' { if ($Property -eq 'Symbol') { $Property = 'JPEG' } } + '\.webp$' { if ($Property -eq 'Symbol') { $Property = 'WEBP' } } + } + $toExport = $inputObject.$Property + if (-not $toExport) { return } + $unresolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FilePath) + $null = New-Item -ItemType File -Force -Path $unresolvedPath + if ($toExport -is [xml]) { + $toExport.Save("$unresolvedPath") + } + elseif ($toExport -is [byte[]]) { + Set-Content -Path $unresolvedPath -Value $toExport -AsByteStream + } else { + $toExport > $unresolvedPath + } + + if ($?) { + Get-Item -Path $unresolvedPath + } + } +} diff --git a/Commands/Set-Turtle.ps1 b/Commands/Set-Turtle.ps1 new file mode 100644 index 0000000..e31ed80 --- /dev/null +++ b/Commands/Set-Turtle.ps1 @@ -0,0 +1,61 @@ +function Set-Turtle { + <# + .SYNOPSIS + Sets a turtle. + .DESCRIPTION + Changes properties of a turtle, and returns the turtle. + .EXAMPLE + New-Turtle | + Move-Turtle SierpinskiTriangle 20 3 | + Set-Turtle -Property Stroke -Value '#4488ff' | + Save-Turtle "./SierpinskiTriangle.svg" + #> + param( + # The property of the turtle to set. + [Parameter(Mandatory)] + [ArgumentCompleter({ + param ( $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters ) + $turtleType = Get-TypeData -TypeName Turtle + $propertyNames = @(foreach ($memberName in $turtleType.Members.Keys) { + if ($turtleType.Members[$memberName] -is [System.Management.Automation.Runspaces.ScriptPropertyData] -and + $turtleType.Members[$memberName].SetScriptBlock) { + $memberName + } + }) + + if ($wordToComplete) { + return $propertyNames -like "$wordToComplete*" + } else { + return $propertyNames + } + })] + [string] + $Property, + + # The value to set. + [PSObject] + $Value, + + # The turtle input object. + [Parameter(ValueFromPipeline)] + [Alias('Turtle')] + [PSObject] + $InputObject + ) + + process { + # If there is no input object, return. + if (-not $inputObject) { return } + # Get the property to set. + $propInfo = $inputObject.psobject.properties[$property] + # If the property is not settable, return an error. + if (-not $propInfo.SetterScript) { + Write-Error "Property '$property' can not be set." + return + } + # set the property value. + $inputObject.$property = $Value + # return our input for the next step of the pipeline. + return $inputObject + } +} diff --git a/Examples/BoxFractal.png b/Examples/BoxFractal.png new file mode 100644 index 0000000..c35cc3c Binary files /dev/null and b/Examples/BoxFractal.png differ diff --git a/Examples/BoxFractal.svg b/Examples/BoxFractal.svg new file mode 100644 index 0000000..ddf0ef3 --- /dev/null +++ b/Examples/BoxFractal.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Examples/BoxFractal.turtle.ps1 b/Examples/BoxFractal.turtle.ps1 new file mode 100644 index 0000000..56fb321 --- /dev/null +++ b/Examples/BoxFractal.turtle.ps1 @@ -0,0 +1,6 @@ +#requires -Module Turtle +$boxFractalTurtle = Move-Turtle BoxFractal 15 5 | + Set-Turtle Stroke '#4488ff' +$boxFractalTurtle | Save-Turtle "./BoxFractal.svg" +$boxFractalTurtle | Save-Turtle "./BoxFractal.png" -Property PNG + diff --git a/Examples/BoxFractal1.svg b/Examples/BoxFractal1.svg new file mode 100644 index 0000000..0ec0bd7 --- /dev/null +++ b/Examples/BoxFractal1.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Examples/BoxFractal2.svg b/Examples/BoxFractal2.svg new file mode 100644 index 0000000..e3813f5 --- /dev/null +++ b/Examples/BoxFractal2.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Examples/BoxFractal3.svg b/Examples/BoxFractal3.svg new file mode 100644 index 0000000..1099478 --- /dev/null +++ b/Examples/BoxFractal3.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Examples/EndlessBoxFractal.svg b/Examples/EndlessBoxFractal.svg new file mode 100644 index 0000000..e17ad74 --- /dev/null +++ b/Examples/EndlessBoxFractal.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Examples/EndlessBoxFractal.turtle.ps1 b/Examples/EndlessBoxFractal.turtle.ps1 new file mode 100644 index 0000000..63a8332 --- /dev/null +++ b/Examples/EndlessBoxFractal.turtle.ps1 @@ -0,0 +1,24 @@ +Push-Location $PSScriptRoot +$turtle = turtle BoxFractal 20 4 | + set-turtle -property Fill -value '#4488ff' | + set-turtle -property backgroundColor '#224488' | + Set-Turtle -Property PatternTransform @{scale=0.5} | + Set-Turtle -Property PatternAnimation -Value " + + + + + + + + + + + + " + + + + +$turtle | save-turtle -Path ./EndlessBoxFractal.svg -Property Pattern +Pop-Location \ No newline at end of file diff --git a/Examples/EndlessHilbert.svg b/Examples/EndlessHilbert.svg new file mode 100644 index 0000000..81b1ddb --- /dev/null +++ b/Examples/EndlessHilbert.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Examples/EndlessHilbert.turtle.ps1 b/Examples/EndlessHilbert.turtle.ps1 new file mode 100644 index 0000000..b6e8760 --- /dev/null +++ b/Examples/EndlessHilbert.turtle.ps1 @@ -0,0 +1,23 @@ +Push-Location $PSScriptRoot +$turtle = turtle HilbertCurve 20 3 | + set-turtle -property Fill -value '#4488ff' | + set-turtle -property backgroundColor '#224488' | + Set-Turtle -Property PatternAnimation -Value " + + + + + + + + + + + + " + + + + +$turtle | save-turtle -Path ./EndlessHilbert.svg -Property Pattern +Pop-Location \ No newline at end of file diff --git a/Examples/EndlessSierpinski.turtle.ps1 b/Examples/EndlessSierpinski.turtle.ps1 new file mode 100644 index 0000000..f6d46bc --- /dev/null +++ b/Examples/EndlessSierpinski.turtle.ps1 @@ -0,0 +1,32 @@ +#requires -Module Turtle +Push-Location $PSScriptRoot +$turtle = turtle SierpinskiTriangle 25 5 | + Set-Turtle PatternTransform @{ + scale = 0.66 + } | + Set-Turtle Stroke '#4488ff' + +$turtle.PatternAnimation += " + +" + +$turtle.PatternAnimation += " + +" + +$turtle.PatternAnimation += " + +" + +$turtle.PatternAnimation += " + +" + +$turtle.PatternAnimation += " + +" + +$turtle | + Save-Turtle -Path "./EndlessSierpinskiTrianglePattern.svg" -Property Pattern + +Pop-Location \ No newline at end of file diff --git a/Examples/EndlessSierpinskiTrianglePattern.svg b/Examples/EndlessSierpinskiTrianglePattern.svg new file mode 100644 index 0000000..02e75bf --- /dev/null +++ b/Examples/EndlessSierpinskiTrianglePattern.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/Examples/EndlessSnowflake.svg b/Examples/EndlessSnowflake.svg new file mode 100644 index 0000000..67f9356 --- /dev/null +++ b/Examples/EndlessSnowflake.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/Examples/EndlessSnowflake.turtle.ps1 b/Examples/EndlessSnowflake.turtle.ps1 new file mode 100644 index 0000000..86fa92f --- /dev/null +++ b/Examples/EndlessSnowflake.turtle.ps1 @@ -0,0 +1,18 @@ +Push-Location $PSScriptRoot +$turtle = turtle KochSnowflake 10 4 | + Set-Turtle -Property PatternTransform -Value @{scale=0.33} | + set-turtle -property Fill -value '#4488ff' | + Set-Turtle -Property PatternAnimation -Value ([Ordered]@{ + type = 'scale' ; values = 0.66,0.33, 0.66 ; repeatCount = 'indefinite' ;dur = "23s"; additive = 'sum' + }, [Ordered]@{ + type = 'rotate' ; values = 0, 360 ;repeatCount = 'indefinite'; dur = "41s"; additive = 'sum' + }, [Ordered]@{ + type = 'skewX' ; values = -30,30,-30;repeatCount = 'indefinite';dur = "83s";additive = 'sum' + }, [Ordered]@{ + type = 'skewY' ; values = 30,-30, 30;repeatCount = 'indefinite';additive = 'sum';dur = "103s" + }, [Ordered]@{ + type = 'translate';values = "0 0","42 42", "0 0";repeatCount = 'indefinite';additive = 'sum';dur = "117s" + }) + +$turtle | save-turtle -Path ./EndlessSnowflake.svg -Property Pattern +Pop-Location \ No newline at end of file diff --git a/Examples/Hexagon.svg b/Examples/Hexagon.svg new file mode 100644 index 0000000..c815c3b --- /dev/null +++ b/Examples/Hexagon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Examples/HexagonPattern.svg b/Examples/HexagonPattern.svg new file mode 100644 index 0000000..fbf53e6 --- /dev/null +++ b/Examples/HexagonPattern.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Examples/KochSnowflakePattern.svg b/Examples/KochSnowflakePattern.svg new file mode 100644 index 0000000..d1da02b --- /dev/null +++ b/Examples/KochSnowflakePattern.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Examples/SierpinskiTriangle.png b/Examples/SierpinskiTriangle.png new file mode 100644 index 0000000..5201700 Binary files /dev/null and b/Examples/SierpinskiTriangle.png differ diff --git a/Examples/SierpinskiTriangle.svg b/Examples/SierpinskiTriangle.svg new file mode 100644 index 0000000..9aa7e52 --- /dev/null +++ b/Examples/SierpinskiTriangle.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Examples/SierpinskiTriangle.turtle.ps1 b/Examples/SierpinskiTriangle.turtle.ps1 new file mode 100644 index 0000000..1761857 --- /dev/null +++ b/Examples/SierpinskiTriangle.turtle.ps1 @@ -0,0 +1,6 @@ +$myName = $MyInvocation.MyCommand.Name -replace '\.turtle\.ps1$' +$turtle = + Move-Turtle SierpinskiTriangle 15 5 | + Set-Turtle -Property Stroke -Value '#4488ff' +$turtle | Save-Turtle "./$myName.svg" +$turtle | Save-Turtle "./$myName.png" -Property PNG diff --git a/Examples/Square.svg b/Examples/Square.svg new file mode 100644 index 0000000..403ea3a --- /dev/null +++ b/Examples/Square.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 315ad9d..279dbc5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,342 @@ # Turtle -Turtle Graphics in PowerShell + +
+ SierpinskiTriangle +
+ + + +## Turtles in a PowerShell + +[Turtle Graphics](https://en.wikipedia.org/wiki/Turtle_graphics) are a great way to learn programming and describe shapes. + +Turtle graphics start really simple. + +Imagine we are a turtle dragging a pen. + +We can draw almost any shape by moving. + +We can only really move in two ways: + +We can turn, and we can take a step forward. + +Turtle graphics starts with these two operations: + +* Rotate() rotates the turtle +* Forward() moves forward + +We can easily keep a list of these steps in memory, and draw them with [SVG](https://developer.mozilla.org/en-US/docs/Web/SVG). + +We can make Turtle in any language. + +This module makes Turtle in PowerShell. +### Installing and Importing + +We can install Turtle from the PowerShell Gallery: + +~~~PowerShell +Install-Module Turtle -Scope CurrentUser -Force +~~~ + +Then we can import it like any other module + +~~~PowerShell +Import-Module Turtle -Force -PassThru +~~~ + +#### Cloning and Importing + +You can also clone the repository and import the module + +~~~PowerShell + +git clone https://github.com/PowerShellWeb/Turtle +cd ./Turtle +Import-Module ./ -Force -PassThru + +~~~ + +### Turtle GitHub Action + +Turtle has a GitHub action, and can be run in a workflow. + +To use the turtle action, simply refer to this repository: + +~~~yaml +- name: UseTurtle + uses: StartAutomating/Turtle@main +~~~ + +This will run any *.turtle.ps1 files found in your repository, and check in any files that have changed. + +What does this give us? + +**We Can Generate Turtle Graphics in GitHub Workflows** + +### Getting Started + +Once we've imported Turtle, we can create any number of turtles, and control them with commands and methods. + +The turtle is represented as an object, and any number of commands can make or move turtles. + +* New-Turtle created a turtle +* Move-Turtle performs a single turtle movement +* Set-Turtle changes the turtle's properties +* Save-Turtle saves the output of a turtle. + +Last but not least: Get-Turtle lets you run multiple steps of turtle, and is aliased to urtle. + + +### Drawing Squares + +
+ +Square +
+ +Let's start simple, by drawing a square with a series of commands. + +~~~PowerShell + +New-Turtle | + Move-Turtle Forward 10 | + Move-Turtle Rotate 90 | + Move-Turtle Forward 10 | + Move-Turtle Rotate 90 | + Move-Turtle Forward 10 | + Move-Turtle Rotate 90 | + Move-Turtle Forward 10 | + Move-Turtle Rotate 90 | + Save-Turtle "./Square.svg" + +~~~ + +We can also write this using a method chain: + +~~~PowerShell +$turtle = New-Turtle +$turtle. + Forward(10).Rotate(90). + Forward(10).Rotate(90). + Forward(10).Rotate(90). + Forward(10).Rotate(90). + Symbol.Save("$pwd/Square.svg") +~~~ + +Or we could use a loop: + +~~~PowerShell +$turtle = New-Turtle +foreach ($n in 1..4) { + $turtle = $turtle.Forward(10).Rotate(90) +} +$turtle | Save-Turtle ./Square.svg +~~~ + +Or we could use `Get-Turtle` directly. + +~~~PowerShell +turtle forward 10 rotate 90 forward 10 rotate 90 forward 10 rotate 90 forward 10 rotate 90 | + Save-Turtle ./Square.svg +~~~ + +Or we could use `Get-Turtle` with a bit of PowerShell multiplication magic: + +~~~PowerShell +turtle ('forward',10,'rotate',90 * 4) | + Save-Turtle ./Square.svg +~~~ + +This just demonstrates how we can construct shapes out of these two simple primitive steps. + +There are a shell of a lot of ways you can draw any shape. + +Turtle has many methods to help you draw, including a convenience method for squares. + +So our shortest square can be written as: + +~~~PowerShell +turtle square 10 | Save-Turtle ./Square.svg +~~~ + +### Drawing Other Shapes + +We can use the same techniques to construct other shapes. + +For example, this builds us a hexagon: + +~~~PowerShell +$turtle = New-Turtle + +foreach ($n in 1..6) { + $turtle = $turtle.Forward(10).Rotate(60) +} + +$turtle | + Save-Turtle "./Hexagon.svg" +~~~ +
+ +Hexagon +
+Because this Turtle generates SVG, we can also use it to create patterns. + +~~~PowerShell + + turtle ('Forward', 10, 'Rotate', 60 * 6) | + Set-Turtle -Property Stroke '#4488ff' | + Save-Turtle -Path ./Examples/HexagonPattern.svg -Property Pattern + +~~~ +
+Hexagon Pattern +
+ +### Drawing Fractals + +Turtle is often used to draw fractals. + +Many fractals can be described in something called a [L-System](https://en.wikipedia.org/wiki/L-system) (short for Lindenmayer system) + +L-Systems describe: + +* An initial state (called an Axiom) +* A series of rewriting rules +* The way each variable should be interpreted. + +For example, let's show how we contruct the [Box Fractal](https://en.wikipedia.org/wiki/Vicsek_fractal) + +Our Axiom is F-F-F-F. + +This should look familiar: it's a shorthand for the squares we drew earlier. + +It basically reads "go forward, then left, four times" + +Our Rule is F = 'F-F+F+F-F'. + +This means every time we encounter F, we want to replace it with F-F+F+F-F. + +This will turn our one box into 6 new boxes. If we repeat it again, we'll get 36 boxes. Once more and we're at 216 boxes. + +Lets show the first three generations of the box fractal: + +~~~PowerShell + + Turtle BoxFractal 5 1 | + Set-Turtle Stroke '#4488ff' | + Save-Turtle ./Examples/BoxFractal1.svg + + + + Turtle BoxFractal 5 2 | + Set-Turtle Stroke '#4488ff' | + Save-Turtle ./Examples/BoxFractal2.svg + + + + Turtle BoxFractal 5 3 | + Set-Turtle Stroke '#4488ff' | + Save-Turtle ./Examples/BoxFractal3.svg + +~~~ + + +
+Box Fractal 1 +Box Fractal 2 +Box Fractal 3 +
+This implementation of Turtle has quite a few built-in fractals. + +For example, here is an example of a pattern comprised of Koch Snowflakes: + +~~~PowerShell + + turtle KochSnowflake 2.5 4 | + Set-Turtle -Property StrokeWidth '0.1%' | + Set-Turtle -Property Stroke '#4488ff' | + Set-Turtle -Property PatternTransform -Value @{scale = 0.5 } | + Save-Turtle -Path ./Examples/KochSnowflakePattern.svg -Property Pattern + +~~~ +
+Snowflake Pattern +
+We can also animate the pattern, for endless variety: + +~~~PowerShell +$turtle = turtle KochSnowflake 10 4 | + Set-Turtle -Property PatternTransform -Value @{scale=0.33} | + set-turtle -property Fill -value '#4488ff' | + Set-Turtle -Property PatternAnimation -Value ([Ordered]@{ + type = 'scale' ; values = 0.66,0.33, 0.66 ; repeatCount = 'indefinite' ;dur = "23s"; additive = 'sum' + }, [Ordered]@{ + type = 'rotate' ; values = 0, 360 ;repeatCount = 'indefinite'; dur = "41s"; additive = 'sum' + }, [Ordered]@{ + type = 'skewX' ; values = -30,30,-30;repeatCount = 'indefinite';dur = "83s";additive = 'sum' + }, [Ordered]@{ + type = 'skewY' ; values = 30,-30, 30;repeatCount = 'indefinite';additive = 'sum';dur = "103s" + }, [Ordered]@{ + type = 'translate';values = "0 0","42 42", "0 0";repeatCount = 'indefinite';additive = 'sum';dur = "117s" + }) + +$turtle | save-turtle -Path ./EndlessSnowflake.svg -Property Pattern +Pop-Location +~~~ +
+Endless Snowflake Pattern +
+ +### Turtles in HTML + +SVG is HTML. + +So, because our Turtle is built atop of an SVG path, our Turtle _is_ HTML. + +Don't believe me? Try this? + +~~~PowerShell +turtle SierpinskiTriangle | + Set-Turtle Stroke '#4488ff' | + Save-Turtle ./SierpinskiTriangle.html +~~~ + +Anything we do with our turtle should work within a webpage. + +There are a few properties of the turtle that may be helpful: + +* .Canvas returns the turtle rendered in an HTML canvas +* .OffsetPath returns the turtle as an offset path +* .ClipPath returns the turtle as a clip path + + +### Turtles in Raster + +Because our Turtle can be painted onto an HTML canvas, we can easily turn it into a raster format, like PNG. + +This works by launching the browser in headless mode, rasterizing the image, and returning the bytes. + +Any turtle can be saved as a PNG, JPEG, and WEBP. + +~~~PowerShell +turtle SierpinskiTriangle | + Set-Turtle Stroke '#4488ff' | + Save-Turtle ./SierpinskiTriangle.png +~~~ + + +### Turtles are Cool + +You should now have some sense of how cool Turtle graphics can be, and how easy it is to get stared. + +Play around. Draw something. Please provide feedback by filing an issue or starting a discussion. + +Open an issue if you want a new shape or fractal. + +File a pull request if you have some cool changes to make. + +Have fun! + +Hope this helps! + + diff --git a/README.md.ps1 b/README.md.ps1 new file mode 100644 index 0000000..5078f52 --- /dev/null +++ b/README.md.ps1 @@ -0,0 +1,467 @@ +<# +.SYNOPSIS + Turtle Graphics in PowerShell +.EXAMPLE + .\README.md.ps1 > .\README.md +#> +#requires -Module Turtle +param() + +#region Introduction + +@" +# Turtle + +
+ SierpinskiTriangle +
+ + + +## Turtles in a PowerShell + +[Turtle Graphics](https://en.wikipedia.org/wiki/Turtle_graphics) are a great way to learn programming and describe shapes. + +Turtle graphics start really simple. + +Imagine we are a turtle dragging a pen. + +We can draw almost any shape by moving. + +We can only really move in two ways: + +We can turn, and we can take a step forward. + +Turtle graphics starts with these two operations: + +* `Rotate()` rotates the turtle +* `Forward()` moves forward + +We can easily keep a list of these steps in memory, and draw them with [SVG](https://developer.mozilla.org/en-US/docs/Web/SVG). + +We can make Turtle in any language. + +This module makes Turtle in PowerShell. +"@ + +#endregion Introduction + +#region Installation + +@" +### Installing and Importing + +We can install Turtle from the PowerShell Gallery: + +~~~PowerShell +$({Install-Module Turtle -Scope CurrentUser -Force}) +~~~ + +Then we can import it like any other module + +~~~PowerShell +$({Import-Module Turtle -Force -PassThru}) +~~~ + +#### Cloning and Importing + +You can also clone the repository and import the module + +~~~PowerShell +$({ +git clone https://github.com/PowerShellWeb/Turtle +cd ./Turtle +Import-Module ./ -Force -PassThru +}) +~~~ +"@ + +#endregion Installation + +#region Turtle PowerShell GitHub Action + +@" + +### Turtle GitHub Action + +Turtle has a GitHub action, and can be run in a workflow. + +To use the turtle action, simply refer to this repository: + +~~~yaml +- name: UseTurtle + uses: StartAutomating/Turtle@main +~~~ + +This will run any *.turtle.ps1 files found in your repository, and check in any files that have changed. + +What does this give us? + +**We Can Generate Turtle Graphics in GitHub Workflows** + +"@ + +#endregion Turtle PowerShell GitHub Action + + +#region Getting Started +@" +### Getting Started + +Once we've imported Turtle, we can create any number of turtles, and control them with commands and methods. + +The turtle is represented as an object, and any number of commands can make or move turtles. + +* `New-Turtle` created a turtle +* `Move-Turtle` performs a single turtle movement +* `Set-Turtle` changes the turtle's properties +* `Save-Turtle` saves the output of a turtle. + +Last but not least: `Get-Turtle` lets you run multiple steps of turtle, and is aliased to `turtle`. + +"@ + +@" + +### Drawing Squares + +
+$( + $null = Get-Turtle Square 10 | + Set-Turtle -Property Stroke '#4488ff' | + Save-Turtle -Path ./Examples/Square.svg +) +Square +
+ +Let's start simple, by drawing a square with a series of commands. + +~~~PowerShell +$( +$drawSquare1 = { +New-Turtle | + Move-Turtle Forward 10 | + Move-Turtle Rotate 90 | + Move-Turtle Forward 10 | + Move-Turtle Rotate 90 | + Move-Turtle Forward 10 | + Move-Turtle Rotate 90 | + Move-Turtle Forward 10 | + Move-Turtle Rotate 90 | + Save-Turtle "./Square.svg" +} +$drawSquare1 +) +~~~ + +"@ + + + +@' +We can also write this using a method chain: + +~~~PowerShell +$turtle = New-Turtle +$turtle. + Forward(10).Rotate(90). + Forward(10).Rotate(90). + Forward(10).Rotate(90). + Forward(10).Rotate(90). + Symbol.Save("$pwd/Square.svg") +~~~ + +Or we could use a loop: + +~~~PowerShell +$turtle = New-Turtle +foreach ($n in 1..4) { + $turtle = $turtle.Forward(10).Rotate(90) +} +$turtle | Save-Turtle ./Square.svg +~~~ + +Or we could use `Get-Turtle` directly. + +~~~PowerShell +turtle forward 10 rotate 90 forward 10 rotate 90 forward 10 rotate 90 forward 10 rotate 90 | + Save-Turtle ./Square.svg +~~~ + +Or we could use `Get-Turtle` with a bit of PowerShell multiplication magic: + +~~~PowerShell +turtle ('forward',10,'rotate',90 * 4) | + Save-Turtle ./Square.svg +~~~ + +This just demonstrates how we can construct shapes out of these two simple primitive steps. + +There are a shell of a lot of ways you can draw any shape. + +Turtle has many methods to help you draw, including a convenience method for squares. + +So our shortest square can be written as: + +~~~PowerShell +turtle square 10 | Save-Turtle ./Square.svg +~~~ +'@ + + + +@' + +### Drawing Other Shapes + +We can use the same techniques to construct other shapes. + +For example, this builds us a hexagon: + +~~~PowerShell +$turtle = New-Turtle + +foreach ($n in 1..6) { + $turtle = $turtle.Forward(10).Rotate(60) +} + +$turtle | + Save-Turtle "./Hexagon.svg" +~~~ +'@ + +@" +
+$( +$null = turtle ('Forward', 10, 'Rotate', 60 * 6) | + Set-Turtle -Property Stroke '#4488ff' | + Save-Turtle -Path ./Examples/Hexagon.svg +) +Hexagon +
+"@ + +@" +Because this Turtle generates SVG, we can also use it to create patterns. +"@ + +$MakeHexagonPattern = { + turtle ('Forward', 10, 'Rotate', 60 * 6) | + Set-Turtle -Property Stroke '#4488ff' | + Save-Turtle -Path ./Examples/HexagonPattern.svg -Property Pattern +} + + +@" + +~~~PowerShell +$MakeHexagonPattern +~~~ +"@ + +$HexPattern = . $MakeHexagonPattern + +@" +
+Hexagon Pattern +
+"@ + + +#region LSystems + +$box1 = { + Turtle BoxFractal 5 1 | + Set-Turtle Stroke '#4488ff' | + Save-Turtle ./Examples/BoxFractal1.svg +} + +$box2 = { + Turtle BoxFractal 5 2 | + Set-Turtle Stroke '#4488ff' | + Save-Turtle ./Examples/BoxFractal2.svg +} + +$box3 = { + Turtle BoxFractal 5 3 | + Set-Turtle Stroke '#4488ff' | + Save-Turtle ./Examples/BoxFractal3.svg +} + +@" + +### Drawing Fractals + +Turtle is often used to draw fractals. + +Many fractals can be described in something called a [L-System](https://en.wikipedia.org/wiki/L-system) (short for Lindenmayer system) + +L-Systems describe: + +* An initial state (called an Axiom) +* A series of rewriting rules +* The way each variable should be interpreted. + +For example, let's show how we contruct the [Box Fractal](https://en.wikipedia.org/wiki/Vicsek_fractal) + +Our Axiom is `F-F-F-F`. + +This should look familiar: it's a shorthand for the squares we drew earlier. + +It basically reads "go forward, then left, four times" + +Our Rule is `F = 'F-F+F+F-F'`. + +This means every time we encounter `F`, we want to replace it with `F-F+F+F-F`. + +This will turn our one box into 6 new boxes. If we repeat it again, we'll get 36 boxes. Once more and we're at 216 boxes. + +Lets show the first three generations of the box fractal: + +~~~PowerShell +$box1 + +$box2 + +$box3 +~~~ +$( + $null = @( + . $box1 + . $box2 + . $box3 + ) +) + +
+Box Fractal 1 +Box Fractal 2 +Box Fractal 3 +
+"@ + + +@" +This implementation of Turtle has quite a few built-in fractals. + +For example, here is an example of a pattern comprised of Koch Snowflakes: +"@ + +$MakeSnowflakePattern = { + turtle KochSnowflake 2.5 4 | + Set-Turtle -Property StrokeWidth '0.1%' | + Set-Turtle -Property Stroke '#4488ff' | + Set-Turtle -Property PatternTransform -Value @{scale = 0.5 } | + Save-Turtle -Path ./Examples/KochSnowflakePattern.svg -Property Pattern +} + + +@" + +~~~PowerShell +$MakeSnowflakePattern +~~~ +"@ + +$SnowFlakePattern = . $MakeSnowflakePattern + +@" +
+Snowflake Pattern +
+"@ + +@" +We can also animate the pattern, for endless variety: + +~~~PowerShell +$( + @(Get-Content ./Examples/EndlessSnowflake.turtle.ps1 | + Select-Object -Skip 1) -join [Environment]::NewLine +) +~~~ +"@ + +@" +
+Endless Snowflake Pattern +
+"@ + +#endregion LSystems + +#region Turtles in HTML +@" + +### Turtles in HTML + +SVG is HTML. + +So, because our Turtle is built atop of an SVG path, our Turtle _is_ HTML. + +Don't believe me? Try this? + +~~~PowerShell +turtle SierpinskiTriangle | + Set-Turtle Stroke '#4488ff' | + Save-Turtle ./SierpinskiTriangle.html +~~~ + +Anything we do with our turtle should work within a webpage. + +There are a few properties of the turtle that may be helpful: + +* `.Canvas` returns the turtle rendered in an HTML canvas +* `.OffsetPath` returns the turtle as an offset path +* `.ClipPath` returns the turtle as a clip path + +"@ +#endregion Turtles in HTML + + +#region Turtles in PNG +@" + +### Turtles in Raster + +Because our Turtle can be painted onto an HTML canvas, we can easily turn it into a raster format, like PNG. + +This works by launching the browser in headless mode, rasterizing the image, and returning the bytes. + +Any turtle can be saved as a `PNG`, `JPEG`, and `WEBP`. + +~~~PowerShell +turtle SierpinskiTriangle | + Set-Turtle Stroke '#4488ff' | + Save-Turtle ./SierpinskiTriangle.png +~~~ + +"@ +#endregion Turtles in PNG + +#region Call To Action +@" + +### Turtles are Cool + +You should now have some sense of how cool Turtle graphics can be, and how easy it is to get stared. + +Play around. Draw something. Please provide feedback by filing an issue or starting a discussion. + +Open an issue if you want a new shape or fractal. + +File a pull request if you have some cool changes to make. + +Have fun! + +Hope this helps! + +"@ +#endregion Call To Action + + +# "![SierpinskiTriangle](./Examples/EndlessSierpinskiTrianglePattern.svg)" + + +"" + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7151e7b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security + +We take security seriously. If you believe you have discovered a vulnerability, please [file an issue](https://github.com/PowerShellWeb/Turtle/issues). + +## Special Security Considerations + +This implementation of Turtle is built with PowerShell, and can run in a GitHub action. + +While the majority of the module does not allow for direct script input, declaring a new L-System involves using a custom ScriptBlock. + +In theory, this could be a code injection vector. + +In practice, this a simple risk to mitigate: do not allow custom ScriptBlocks to provided as input to web forms, and watch out for the injection of dangerous L-systems declarations in any potential pull request. + +If there are additional special security considerations not covered in this document, please [file an issue](https://github.com/PowerShellWeb/Turtle/issues). diff --git a/Turtle.psd1 b/Turtle.psd1 new file mode 100644 index 0000000..6341e1c --- /dev/null +++ b/Turtle.psd1 @@ -0,0 +1,92 @@ +@{ + # Version number of this module. + ModuleVersion = '0.1' + # Description of the module + Description = "Turtles in a PowerShell" + # Script module or binary module file associated with this manifest. + RootModule = 'Turtle.psm1' + # ID used to uniquely identify this module + GUID = '71b29fe9-fc00-4531-82ca-db5d2630d72c' + # Author of this module + Author = 'James Brundage' + + # Company or vendor of this module + CompanyName = 'Start-Automating' + # Copyright statement for this module + Copyright = '2025 Start-Automating' + # Type files (.ps1xml) to be loaded when importing this module + TypesToProcess = @('Turtle.types.ps1xml') + # Format files (.ps1xml) to be loaded when importing this module + # FormatsToProcess = @() + # Functions 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 functions to export. + VariablesToExport = '*' + AliasesToExport = '*' + PrivateData = @{ + PSData = @{ + # Tags applied to this module. These help with module discovery in online galleries. + Tags = 'PowerShell', 'Turtle', 'SVG', 'Graphics', 'Drawing', 'L-System', 'Fractal' + # A URL to the main website for this project. + ProjectURI = 'https://github.com/PowerShellWeb/Turtle' + # A URL to the license for this module. + LicenseURI = 'https://github.com/PowerShellWeb/Turtle/blob/main/LICENSE' + ReleaseNotes = @' +## Turtle 0.1: + +* Initial Release +* Builds a Turtle Graphics engine in PowerShell +* Core commands + * `Get-Turtle` (alias `turtle`) runs multiple moves + * `New-Turtle` create a turtle + * `Move-Turtle` performas a single move + * `Set-Turtle` changes a turtle + * `Save-Turtle` saves a turtle + +~~~PowerShell +turtle Forward 10 Rotate 120 Forward 10 Roate 120 Forward 10 Rotate 120 | + Set-Turtle Stroke '#4488ff' | + Save-Turtle ./Triangle.svg +~~~ + +* Core Object + * `.Heading` controls the turtle heading + * `.Steps` stores a list of moves as an SVG path + * `.IsPenDown` controls the pen + * `.Forward()` moves forward at heading + * `.Rotate()` rotates the heading + * `.Square()` draws a square + * `.Polygon()` draws a polygon + * `.Circle()` draws a circle (or partial circle) +* LSystems + * Turtle can draw a L system. Several are included: + * `BoxFractal` + * `GosperCurve` + * `HilbertCurve` + * `KochCurve` + * `KochIsland` + * `KochSnowflake` + * `MooreCurve` + * `PeanoCurve` + * `SierpinskiTriangle` + * `SierpinskiCurve` + * `SierpinskiSquareCurve` + * `SierpinskiArrowheadCurve` + * `TerdragonCurve` + * `TwinDragonCurve` + +~~~PowerShell +turtle SierpinskiTriangle 10 4 | + Set-Turtle Stroke '#4488ff' | + Save-Turtle ./SierpinskiTriangle.svg +~~~ + +'@ + } + } + + # HelpInfo URI of this module + # HelpInfoURI = '' + + # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. + # DefaultCommandPrefix = '' +} + diff --git a/Turtle.psm1 b/Turtle.psm1 new file mode 100644 index 0000000..41a1efb --- /dev/null +++ b/Turtle.psm1 @@ -0,0 +1,36 @@ +$commandsPath = Join-Path $PSScriptRoot Commands +:ToIncludeFiles foreach ($file in (Get-ChildItem -Path "$commandsPath" -Filter "*-*" -Recurse)) { + if ($file.Extension -ne '.ps1') { continue } # Skip if the extension is not .ps1 + foreach ($exclusion in '\.[^\.]+\.ps1$') { + if (-not $exclusion) { continue } + if ($file.Name -match $exclusion) { + continue ToIncludeFiles # Skip excluded files + } + } + . $file.FullName +} + +$myModule = $MyInvocation.MyCommand.ScriptBlock.Module +$ExecutionContext.SessionState.PSVariable.Set($myModule.Name, $myModule) +$myModule.pstypenames.insert(0, $myModule.Name) + +New-PSDrive -Name $MyModule.Name -PSProvider FileSystem -Scope Global -Root $PSScriptRoot -ErrorAction Ignore + +if ($home) { + $MyModuleProfileDirectory = Join-Path ([Environment]::GetFolderPath("LocalApplicationData")) $MyModule.Name + if (-not (Test-Path $MyModuleProfileDirectory)) { + $null = New-Item -ItemType Directory -Path $MyModuleProfileDirectory -Force + } + New-PSDrive -Name "My$($MyModule.Name)" -PSProvider FileSystem -Scope Global -Root $MyModuleProfileDirectory -ErrorAction Ignore +} + +# Set a script variable of this, set to the module +# (so all scripts in this scope default to the correct `$this`) +$script:this = $myModule + +#region Custom +#endregion Custom + +Export-ModuleMember -Alias * -Function * -Variable $myModule.Name + + diff --git a/Turtle.tests.ps1 b/Turtle.tests.ps1 new file mode 100644 index 0000000..ec2d3d9 --- /dev/null +++ b/Turtle.tests.ps1 @@ -0,0 +1,21 @@ +describe Turtle { + it "Draws things with simple commands" { + $null = $turtle.Clear().Square() + $turtleSquaredPoints = $turtle.Points + $turtleSquaredPoints.Length | Should -Be 8 + $turtleSquaredPoints | + Measure-Object -Sum | + Select-Object -ExpandProperty Sum | + Should -Be 0 + } + + it 'Can draw an L-system, like a Sierpinski triangle' { + $turtle.Clear().SierpinskiTriangle(200, 2, 120).points.Count | + Should -Be 54 + } + + it 'Can rasterize an image, with a little help from chromium' { + $png = New-Turtle | Move-Turtle SierpinskiTriangle 15 5 | Select-Object -ExpandProperty PNG + $png[1..3] -as 'char[]' -as 'string[]' -join '' | Should -Be PNG + } +} diff --git a/Turtle.types.ps1xml b/Turtle.types.ps1xml new file mode 100644 index 0000000..8b5ac9a --- /dev/null +++ b/Turtle.types.ps1xml @@ -0,0 +1,2001 @@ + + + + Turtle + + + PSStandardMembers + + + DefaultDisplayPropertySet + + Heading + Position + + + + + + Back + Backward + + + bk + Backward + + + down + PenDown + + + fd + Forward + + + l + Left + + + LineTo + GoTo + + + MoveTo + Teleport + + + pd + PenDown + + + pu + PenUp + + + r + Right + + + SetPos + GoTo + + + SetPosition + GoTo + + + up + PenUp + + + xPos + xcor + + + yPos + ycor + + + Backward + + + + BoxFractal + + + + Circle + + + + Clear + + + + FillColor + + + + Flower + + + + Forward + + + + GosperCurve + + + + GoTo + + + + HilbertCurve + + + + Jump + + + + KochCurve + + + + KochIsland + + + + KochSnowflake + + + + Left + + + + LSystem + + + + MooreCurve + + + + PeanoCurve + + + + PenColor + + + + PenDown + + + + PenUp + + + + Polygon + + + + Right + + + + Rotate + + + + SierpinskiArrowheadCurve + + + + SierpinskiCurve + + + + SierpinskiSquareCurve + + + + SierpinskiTriangle + + + + Square + + + + Star + + + + Teleport + + + + TerdragonCurve + + + + TwinDragonCurve + + + + xcor + + + + ycor + + + + AnimateMotion + + @("<animateMotion dur='$( + if ($this.AnimateMotionDuration) { + $this.AnimateMotionDuration + } else { + "$(($this.Points.Length / 2 / 10))s" + } +)' repeatCount='indefinite' path='$($this.PathData)' />") -as [xml] + + + + AnimateMotionDuration + + if ($this.'.AnimateMotionDuration') { + return $this.'.AnimateMotionDuration' +} +$thesePoints = $this.Points +if ($thesePoints.Length -eq 0) { + return "$(($thesePoints.Length / 2 / 10))s" +} + + + + param( +[PSObject] +$AnimateMotionDuration +) + +if ($AnimateMotionDuration -is [TimeSpan]) { + $AnimateMotionDuration = $AnimateMotionDuration.TotalSeconds + 's' +} + +if ($AnimateMotionDuration -is [int] -or $AnimateMotionDuration -is [double]) { + $AnimateMotionDuration = "${AnimateMotionDuration}s" +} + +$this | Add-Member -MemberType NoteProperty -Force -Name '.AnimateMotionDuration' -Value $AnimateMotionDuration + + + + + BackgroundColor + + param() + +if ($this.'.BackgroundColor') { + return $this.'.BackgroundColor' +} + + + + param( +[PSObject] +$value +) + +$this | Add-Member NoteProperty -Name '.BackgroundColor' -Value $value -Force + + + + + Canvas + + <# +.SYNOPSIS + Gets a turtle canvas +.DESCRIPTION + Gets a turtle a canvas element. +#> + +@( + $viewBox = $this.ViewBox + $null, $null, $viewX, $viewY = $viewBox + "<style>canvas {max-width: 100%; height: 100%}</style>" + "<canvas id='turtle-canvas' width='$($viewX + 1)' height='$($viewY + 1)'></canvas>" + + "<script>" +@" +window.onload = async function() { + var canvas = document.getElementById('turtle-canvas'); + var ctx = canvas.getContext('2d'); + ctx.strokeStyle = '$($this.Stroke)' + ctx.lineWidth = '$( + if ($this.StrokeWidth -match '%') { + [Math]::Max($viewX, $viewY) * ($this.StrokeWidth -replace '%' -as [double])/100 + } else { + $this.StrokeWidth + } +)' + ctx.fillStyle = '$($this.Fill)' + var p = new Path2D("$($this.PathData)") + ctx.stroke(p) + ctx.fill(p) + + /*Insert-Post-Processing-Here*/ +} +"@ + "</script>" +) + + + + + + ClipPath + + "clip-path: path(`"$($this.PathData)`");" + + + + DataURL + + <# +.SYNOPSIS + Gets the turtle data URL. +.DESCRIPTION + Gets the turtle symbol as a data URL. + + This can be used as an inline image in HTML, CSS, or Markdown. +#> +$thisSymbol = $this.Symbol +$b64 = [Convert]::ToBase64String($OutputEncoding.GetBytes($thisSymbol.outerXml)) +"data:image/svg+xml;base64,$b64" + + + + Fill + + if ($this.'.Fill') { + return $this.'.Fill' +} +return 'transparent' + + + param( + [string]$Fill = 'transparent' +) + +if (-not $this.'.Fill') { + $this | Add-Member -MemberType NoteProperty -Name '.Fill' -Value $Fill -Force +} else { + $this.'.Fill' = $Fill +} + + + + Heading + + <# +.SYNOPSIS + Gets the turtle's heading. +.DESCRIPTION + Gets the current heading of the turtle. +#> +param() +if ($null -ne $this.'.TurtleHeading') { + return $this.'.TurtleHeading' +} +return 0 + + + + + <# +.SYNOPSIS + Sets the turtle's heading. +.DESCRIPTION + Sets the turtle's heading. + + This is one of two key properties of the turtle, the other being its position. +#> +param( +# The new turtle heading. +[double] +$Heading +) + +if ($this -and -not $this.psobject.properties['.TurtleHeading']) { + $this.psobject.properties.add([PSNoteProperty]::new('.TurtleHeading', 0), $false) +} +$this.'.TurtleHeading' = $Heading + +# $this.psobject.properties.add([PSNoteProperty]::new('.TurtleHeading', $Heading), $false) +# $this | Add-Member -MemberType NoteProperty -Force -Name '.TurtleHeading' -Value $Heading +if ($VerbosePreference -ne 'SilentlyContinue') { + Write-Verbose "Heading to $Heading" +} + + + + IsPenDown + + if ($null -ne $this.'.IsPenDown') { return $this.'.IsPenDown' } +return $true + + + + param( +[bool] +$IsDown +) + +$this | + Add-Member -MemberType NoteProperty -Force -Name '.IsPenDown' -Value $IsDown + +if ($VerbosePreference -ne 'SilentlyContinue') { + Write-Verbose "Turtle is now $($IsDown ? 'down' : 'up')" +} + + + + JPEG + + $chromiumNames = 'chromium','chrome','msedge' +foreach ($browserName in $chromiumNames) { + $chromiumCommand = + $ExecutionContext.SessionState.InvokeCommand.GetCommand($browserName,'Application') + if (-not $chromiumCommand) { + $chromiumCommand = + Get-Process -Name $browserName -ErrorAction Ignore | + Select-Object -First 1 -ExpandProperty Path + } + if ($chromiumCommand) { break } +} +if (-not $chromiumCommand) { + Write-Error "No Chromium-based browser found. Please install one of: $($chromiumNames -join ', ')" + return +} + +$pngRasterizer = $this.Canvas -replace '/\*Insert-Post-Processing-Here\*/', @' + const dataUrl = await canvas.toDataURL('image/jpeg') + console.log(dataUrl) + + const newImage = document.createElement('img') + newImage.src = dataUrl + document.body.appendChild(newImage) +'@ + + +$appDataRoot = [Environment]::GetFolderPath("ApplicationData") +$appDataPath = Join-Path $appDataRoot 'Turtle' +$filePath = Join-Path $appDataPath 'Turtle.raster.html' +$null = New-Item -ItemType File -Force -Path $filePath -Value ( + $pngRasterizer -join [Environment]::NewLine +) +# $pngRasterizer > $filePath + +$headlessArguments = @( + '--headless', # run in headless mode + '--dump-dom', # dump the DOM to stdout + '--disable-gpu', # disable GPU acceleration + '--no-sandbox' # disable the sandbox if running in CI/CD +) + +$chromeOutput = & $chromiumCommand @headlessArguments "$filePath" | Out-String +if ($chromeOutput -match '<img\ssrc="data:image/\w+;base64,(?<b64>[^"]+)') { + ,[Convert]::FromBase64String($matches.b64) +} + + + + + Mask + + $segments = @( +"<svg xmlns='http://www.w3.org/2000/svg' width='0%' height='0%'>" + "<defs>" + "<mask id='turtle-mask'>" + $this.Symbol.OuterXml -replace '\<\?[^\>]+\>' + "</mask>" + "</defs>" +"</svg>" +) +[xml]($segments -join '') + + + + Maximum + + <# +.SYNOPSIS + Gets the turtle maximum point. +.DESCRIPTION + Gets the maximum point reached by the turtle. + + Keeping track of this as we go is far more efficient than calculating it from the path. +#> +if ($this.'.Maximum') { + return $this.'.Maximum' +} +return ([pscustomobject]@{ X = 0; Y = 0 }) + + + + Minimum + + if ($this.'.Minimum') { + return $this.'.Minimum' +} +return ([pscustomobject]@{ X = 0; Y = 0 }) + + + + OffsetPath + + "offset-path: $($this.PathData);" + + + + PathAttribute + + if ($this.'.PathAttribute') { + return $this.'.PathAttribute' +} +return [Ordered]@{} + + + param( +[Collections.IDictionary] +$PathAttribute = [Ordered]@{} +) + +if (-not $this.'.PathAttribute') { + $this | Add-Member -MemberType NoteProperty -Name '.PathAttribute' -Value ([Ordered]@{}) -Force +} +foreach ($key in $PathAttribute.Keys) { + $this.'.PathAttribute'[$key] = $PathAttribute[$key] +} + + + + PathClass + + if ($this.'.PathClass') { return $this.'.PathClass'} +return 'foreground-stroke' + + + <# +.SYNOPSIS + Sets the turtle path class +.DESCRIPTION + Sets the css classes that apply to the turtle path. + + This property will rarely be set directly, but can be handy for integrating turtle graphics into custom pages. +#> +param( +$PathClass +) + +$this | Add-Member -MemberType NoteProperty -Force -Name '.PathClass' -Value @($PathClass) + + + + + PathData + + @( + @( + + if ($this.Start.X -and $this.Start.Y) { + "m $($this.Start.x) $($this.Start.y)" + } + else { + @("m" + if ($this.Minimum.X -lt 0) { + -1 * $this.Minimum.X + } else { + 0 + } + if ($this.Minimum.Y -lt 0) { + -1 * $this.Minimum.Y + } else { + 0 + }) -join ' ' + } + ) + $this.Steps + # @("m $($this.Start.x) $($this.Start.y) ") + $this.Steps +) -join ' ' + + + + PathElement + + @( +"<path id='turtle-path' d='$($this.PathData)' stroke='$( + if ($this.Stroke) { $this.Stroke } else { 'currentColor' } +)' stroke-width='$( + if ($this.StrokeWidth) { $this.StrokeWidth } else { '0.1%' } +)' fill='$($this.Fill)' class='$( + $this.PathClass -join ' ' +)' $( + foreach ($pathAttributeName in $this.PathAttribute.Keys) { + " $pathAttributeName='$($this.PathAttribute[$pathAttributeName])'" + } +) />" +) -as [xml] + + + + Pattern + + param() +$segments = @( +$viewBox = $this.ViewBox +$null, $null, $viewX, $viewY = $viewBox +"<svg xmlns='http://www.w3.org/2000/svg' width='100%' height='100%'>" +"<defs>" + "<pattern id='turtle-pattern' patternUnits='userSpaceOnUse' width='$viewX' height='$viewY' transform-origin='50% 50%'$( + if ($this.PatternTransform) { + " patternTransform='" + ( + @(foreach ($key in $this.PatternTransform.Keys) { + "$key($($this.PatternTransform[$key]))" + }) -join ' ' + ) + "'" + } + )>" + $(if ($this.PatternAnimation) { $this.PatternAnimation }) + $this.PathElement.OuterXml + "</pattern>" +"</defs>" +$( + if ($this.BackgroundColor) { + "<rect width='10000%' height='10000%' x='-5000%' y='-5000%' fill='$($this.BackgroundColor)' transform-origin='50% 50%' />" + } +) +"<rect width='10000%' height='10000%' x='-5000%' y='-5000%' fill='url(#turtle-pattern)' transform-origin='50% 50%' />" +"</svg>") + +$segments -join '' -as [xml] + + + + PatternAnimation + + if ($this.'.PatternAnimation') { + return $this.'.PatternAnimation' +} + + + + param( +[PSObject] +$PatternAnimation +) + +$newAnimation = @(foreach ($animation in $PatternAnimation) { + if ($animation -is [Collections.IDictionary]) { + $animationCopy = [Ordered]@{} + $animation + if (-not $animationCopy['attributeType']) { + $animationCopy['attributeType'] = 'XML' + } + if (-not $animationCopy['attributeName']) { + $animationCopy['attributeName'] = 'patternTransform' + } + if ($animationCopy.values -is [object[]]) { + $animationCopy['values'] = $animationCopy['values'] -join ';' + } + + "<animateTransform $( + @(foreach ($key in $animationCopy.Keys) { + " $key='$([Web.HttpUtility]::HtmlAttributeEncode($animationCopy[$key]))'" + }) -join '' + )/>" + } + if ($animation -is [string]) { + $animation + } +}) + +$this | Add-Member -MemberType NoteProperty -Force -Name '.PatternAnimation' -Value $newAnimation + + + + + PatternDataURL + + <# +.SYNOPSIS + Gets the turtle pattern data URL. +.DESCRIPTION + Gets the turtle pattern as a data URL. + + This can be used as an inline image in HTML, CSS, or Markdown. +#> +$thisPattern = $this.Pattern +$b64 = [Convert]::ToBase64String($OutputEncoding.GetBytes($thisPattern.outerXml)) +"data:image/svg+xml;base64,$b64" + + + + PatternMask + + $segments = @( +"<svg xmlns='http://www.w3.org/2000/svg' width='0%' height='0%'>" + "<defs>" + "<mask id='turtle-mask'>" + $this.Pattern.OuterXml -replace '\<\?[^\>]+\>' + "</mask>" + "</defs>" +"</svg>" +) +[xml]($segments -join '') + + + + PatternTransform + + if ($this.'.PatternTransform') { + return $this.'.PatternTransform' +} + + + + param( +[Collections.IDictionary] +$PatternTransform +) + +$this | Add-Member -MemberType NoteProperty -Force -Name '.PatternTransform' -Value $PatternTransform + + + + PNG + + $chromiumNames = 'chromium','chrome','msedge' +foreach ($browserName in $chromiumNames) { + $chromiumCommand = + $ExecutionContext.SessionState.InvokeCommand.GetCommand($browserName,'Application') + if (-not $chromiumCommand) { + $chromiumCommand = + Get-Process -Name $browserName -ErrorAction Ignore | + Select-Object -First 1 -ExpandProperty Path + } + if ($chromiumCommand) { break } +} +if (-not $chromiumCommand) { + Write-Error "No Chromium-based browser found. Please install one of: $($chromiumNames -join ', ')" + return +} + +$pngRasterizer = $this.Canvas -replace '/\*Insert-Post-Processing-Here\*/', @' + const dataUrl = await canvas.toDataURL('image/png') + console.log(dataUrl) + + const newImage = document.createElement('img') + newImage.src = dataUrl + document.body.appendChild(newImage) +'@ + + +$appDataRoot = [Environment]::GetFolderPath("ApplicationData") +$appDataPath = Join-Path $appDataRoot 'Turtle' +$filePath = Join-Path $appDataPath 'Turtle.raster.html' +$null = New-Item -ItemType File -Force -Path $filePath -Value ( + $pngRasterizer -join [Environment]::NewLine +) +# $pngRasterizer > $filePath + +$headlessArguments = @( + '--headless', # run in headless mode + '--dump-dom', # dump the DOM to stdout + '--disable-gpu', # disable GPU acceleration + '--no-sandbox' # disable the sandbox if running in CI/CD +) + +$chromeOutput = & $chromiumCommand @headlessArguments "$filePath" | Out-String +if ($chromeOutput -match '<img\ssrc="data:image/png;base64,(?<b64>[^"]+)') { + ,[Convert]::FromBase64String($matches.b64) +} + + + + + Points + + $this.Steps -replace '[\w-[\d\.E\-]]+' -split '\s+' -ne '' -as [double[]] + + + + Position + + if (-not $this.'.Position') { + $this | Add-Member -MemberType NoteProperty -Force -Name '.Position' -Value [pscustomobject]@{ X = 0; Y = 0 } +} +return $this.'.Position' + + + param([double[]]$xy) +$x, $y = $xy +if (-not $this.'.Position') { + $this | Add-Member -MemberType NoteProperty -Force -Name '.Position' -Value ([pscustomobject]@{ X = 0; Y = 0 }) +} +$this.'.Position'.X += $x +$this.'.Position'.Y += $y +$posX, $posY = $this.'.Position'.X, $this.'.Position'.Y +if (-not $this.'.Minimum') { + $this | Add-Member -MemberType NoteProperty -Force -Name '.Minimum' -Value ([pscustomobject]@{ X = 0; Y = 0 }) +} +if (-not $this.'.Maximum') { + $this | Add-Member -MemberType NoteProperty -Force -Name '.Maximum' -Value ([pscustomobject]@{ X = 0; Y = 0 }) +} +if ($posX -lt $this.'.Minimum'.X) { + $this.'.Minimum'.X = $posX +} +if ($posY -lt $this.'.Minimum'.Y) { + $this.'.Minimum'.Y = $posY +} +if ($posX -gt $this.'.Maximum'.X) { + $this.'.Maximum'.X = $posX +} +if ($posY -gt $this.'.Maximum'.Y) { + $this.'.Maximum'.Y = $posY +} + + + + Steps + + if ($this.'.Steps') { + return $this.'.Steps' +} +return ,@() + + + + <# +.SYNOPSIS + Sets the steps of the turtle. +.DESCRIPTION + Sets the steps of the turtle to the specified array of strings. + + This property will rarely be set directly, but will be updated every time the turtle moves. +#> +param( +[string[]] +$Steps +) + +$this | Add-Member -MemberType NoteProperty -Force -Name '.Steps' -Value @($Steps) + + + + + Stroke + + if ($this.'.Stroke') { + return $this.'.Stroke' +} else { + return 'currentcolor' +} + + + param([string]$value) + +$this | Add-Member -MemberType NoteProperty -Force -Name '.Stroke' -Value $value + + + + StrokeWidth + + if ($this.'.StrokeWidth') { + return $this.'.StrokeWidth' +} else { + return '0.25%' +} + + + param([string]$value) + +$this | Add-Member -MemberType NoteProperty -Force -Name '.StrokeWidth' -Value $value + + + + SVG + + param() +@( +"<svg xmlns='http://www.w3.org/2000/svg' viewBox='$($this.ViewBox)' transform-origin='50% 50%' width='100%' height='100%'>" + $this.PathElement.OuterXml +"</svg>" +) -join '' -as [xml] + + + + Symbol + + <# +.SYNOPSIS + Gets the Turtle as a symbol. +.DESCRIPTION + Returns the turtle as an SVG symbol element, which can be used in other SVG files. + + Symbols allow a shape to be scaled and reused without having the duplicate the drawing commands. + + By default, this will return a SVG defining the symbol and using it to fill the viewport. +.EXAMPLE + Move-Turtle Flower | + Select-Object -ExpandProperty Symbol +#> +param() + +@( + "<svg xmlns='http://www.w3.org/2000/svg' width='100%' height='100%' transform-origin='50% 50%'>" + "<symbol id='turtle-symbol' viewBox='$($this.ViewBox)' transform-origin='50% 50%'>" + $this.PathElement.OuterXml + "</symbol>" + $( + if ($this.BackgroundColor) { + "<rect width='10000%' height='10000%' x='-5000%' y='-5000%' fill='$($this.BackgroundColor)' transform-origin='50% 50%' />" + } + ) + "<use href='#turtle-symbol' width='100%' height='100%' transform-origin='50% 50%' />" + "</svg>" +) -join '' -as [xml] + + + + ViewBox + + if ($this.'.ViewBox') { return $this.'.ViewBox' } + +$viewX = $this.Maximum.X + ($this.Minimum.X * -1) +$viewY = $this.Maximum.Y + ($this.Minimum.Y * -1) + +return 0, 0, $viewX, $viewY + + + + + + param( +[double[]] +$viewBox +) + +if ($viewBox.Length -gt 4) { + $viewBox = $viewBox[0..3] +} +if ($viewBox.Length -lt 4) { + if ($viewBox.Length -eq 3) { + $viewBox = $viewBox[0], $viewBox[1], $viewBox[2],$viewBox[2] + } + if ($viewBox.Length -eq 2) { + $viewBox = 0,0, $viewBox[0], $viewBox[1] + } + if ($viewBox.Length -eq 1) { + $viewBox = 0,0, $viewBox[0], $viewBox[0] + } +} + +if ($viewBox[0] -eq 0 -and + $viewBox[1] -eq 0 -and + $viewBox[2] -eq 0 -and + $viewBox[3] -eq 0 +) { + $viewX = $this.Maximum.X + ($this.Minimum.X * -1) + $viewY = $this.Maximum.Y + ($this.Minimum.Y * -1) + $this.psobject.Properties.Remove('.ViewBox') + return +} + +$this | Add-Member -MemberType NoteProperty -Force -Name '.ViewBox' -Value $viewBox + + + + + WEBP + + $chromiumNames = 'chromium','chrome','msedge' +foreach ($browserName in $chromiumNames) { + $chromiumCommand = + $ExecutionContext.SessionState.InvokeCommand.GetCommand($browserName,'Application') + if (-not $chromiumCommand) { + $chromiumCommand = + Get-Process -Name $browserName -ErrorAction Ignore | + Select-Object -First 1 -ExpandProperty Path + } + if ($chromiumCommand) { break } +} +if (-not $chromiumCommand) { + Write-Error "No Chromium-based browser found. Please install one of: $($chromiumNames -join ', ')" + return +} + +$pngRasterizer = $this.Canvas -replace '/\*Insert-Post-Processing-Here\*/', @' + const dataUrl = await canvas.toDataURL('image/webp') + console.log(dataUrl) + + const newImage = document.createElement('img') + newImage.src = dataUrl + document.body.appendChild(newImage) +'@ + + +$appDataRoot = [Environment]::GetFolderPath("ApplicationData") +$appDataPath = Join-Path $appDataRoot 'Turtle' +$filePath = Join-Path $appDataPath 'Turtle.raster.html' +$null = New-Item -ItemType File -Force -Path $filePath -Value ( + $pngRasterizer -join [Environment]::NewLine +) +# $pngRasterizer > $filePath + +$headlessArguments = @( + '--headless', # run in headless mode + '--dump-dom', # dump the DOM to stdout + '--disable-gpu', # disable GPU acceleration + '--no-sandbox' # disable the sandbox if running in CI/CD +) + +$chromeOutput = & $chromiumCommand @headlessArguments "$filePath" | Out-String +if ($chromeOutput -match '<img\ssrc="data:image/\w+;base64,(?<b64>[^"]+)') { + ,[Convert]::FromBase64String($matches.b64) +} + + + + + X + + $this.Position.X + + + + Y + + $this.Position.Y + + + + DefaultDisplay + Heading +Position + + + + + \ No newline at end of file diff --git a/Types/Turtle/Alias.psd1 b/Types/Turtle/Alias.psd1 new file mode 100644 index 0000000..d783c3e --- /dev/null +++ b/Types/Turtle/Alias.psd1 @@ -0,0 +1,17 @@ +@{ + pd = 'PenDown' + pu = 'PenUp' + fd = 'Forward' + down = 'PenDown' + up = 'PenUp' + l = 'Left' + r = 'Right' + xPos = 'xcor' + yPos = 'ycor' + LineTo = 'GoTo' + SetPos = 'GoTo' + SetPosition = 'GoTo' + MoveTo = 'Teleport' + Back = 'Backward' + bk = 'Backward' +} \ No newline at end of file diff --git a/Types/Turtle/Backward.ps1 b/Types/Turtle/Backward.ps1 new file mode 100644 index 0000000..e07473b --- /dev/null +++ b/Types/Turtle/Backward.ps1 @@ -0,0 +1,19 @@ +<# +.SYNOPSIS + Moves backwards +.DESCRIPTION + Moves the turtle backwards by a specified distance. +.EXAMPLE + Move-Turtle Forward 10 | + Move-Turtle Backward 5 | + Move-Turtle Rotate 90 | + Move-Turtle Forward 20 | + Save-Turtle ./DrawT.svg +#> +param( +# The distance to move backwards +[double] +$Distance = 10 +) + +$this.Forward($Distance * -1) diff --git a/Types/Turtle/BoxFractal.ps1 b/Types/Turtle/BoxFractal.ps1 new file mode 100644 index 0000000..955db26 --- /dev/null +++ b/Types/Turtle/BoxFractal.ps1 @@ -0,0 +1,31 @@ +<# +.EXAMPLE + $turtle.PatternAnimation = '' + $turtle.Clear().BoxFractal().Pattern.Save("$pwd/BoxFractal.svg") + +.EXAMPLE + $turtle.Clear() + $turtle.BoxFractal(10,4) + $turtle.PatternTransform = @{ + 'scale' = 0.9 + } + $turtle.PatternAnimation = " + + + + " + $turtle.Pattern.Save("$pwd/BoxFractal2.svg") +#> +param( + [double]$Size = 20, + [int]$Order = 4, + [double]$Angle = 90 +) +return $this.LSystem('F-F-F-F', [Ordered]@{ + F = 'F-F+F+F-F' +}, $Order, [Ordered]@{ + '\+' = { $this.Rotate($Angle) } + '-' = { $this.Rotate($Angle * -1) } + 'F' = { $this.Forward($Size) } +}) + diff --git a/Types/Turtle/Circle.ps1 b/Types/Turtle/Circle.ps1 new file mode 100644 index 0000000..8b1c187 --- /dev/null +++ b/Types/Turtle/Circle.ps1 @@ -0,0 +1,71 @@ +<# +.SYNOPSIS + Draws a circle. +.DESCRIPTION + Draws a whole or partial circle using turtle graphics. + + That is, it draws a circle by moving the turtle forward and rotating it. + + To draw a semicircle, use an extent of 0.5. + + To draw a quarter circle, use an extent of 0.25. + + To draw a half hexagon, use an extent of 0.5 and step count of 6. +.EXAMPLE + $turtle = New-Turtle + $turtle.Circle(10).Pattern.Save("$pwd/CirclePattern.svg") +.EXAMPLE + Move-Turtle Circle 10 | + Save-Turtle "$pwd/CirclePattern.svg" -Property Pattern +.EXAMPLE + $turtle = New-Turtle | + Move-Turtle Forward 10 | + Move-Turtle Rotate -90 | + Move-Turtle Circle 5 | + Move-Turtle Circle 5 .5 | + Move-Turtle Rotate -90 | + Move-Turtle Forward 10 | Save-Turtle .\DashDotDash.svg +.EXAMPLE + $turtle = New-Turtle | + Move-Turtle Forward 20 | + Move-Turtle Circle 5 .75 | + Move-Turtle Forward 20 | + Move-Turtle Circle 5 .75 | + Move-Turtle Forward 20 | + Move-Turtle Circle 5 .75 | + Move-Turtle Forward 20 | + Move-Turtle Circle 5 .75 | + Save-Turtle .\CommandSymbol.svg +#> +param( +[double]$Radius = 10, +[double]$Extent = 1, +[int]$StepCount = 180 +) + +$circumference = 2 * [math]::PI * $Radius +$circumferenceStep = $circumference / $StepCount + +if ($extent -eq 0) { return $this} + +$extentMultiplier = if ($extent -gt 0) { 1 } else { -1 } + +$currentExtent = 0 +$maxExtent = [math]::Abs($extent) + +$extentStep = 1/$StepCount + +$null = foreach ($n in 1..$StepCount) { + + $this.Forward($circumferenceStep) + $currentExtent += $extentStep + + if ($n -le $StepCount -and $currentExtent -le $maxExtent) { + $this.Rotate( (360 / $StepCount) * $extentMultiplier) + } + + if ($currentExtent -gt $maxExtent) { + break + } +} +return $this \ No newline at end of file diff --git a/Types/Turtle/Clear.ps1 b/Types/Turtle/Clear.ps1 new file mode 100644 index 0000000..c88c6d5 --- /dev/null +++ b/Types/Turtle/Clear.ps1 @@ -0,0 +1,7 @@ +$this.Heading = 0 +$this.Steps = @() +$this | Add-Member -MemberType NoteProperty -Force -Name '.Position' -Value ([pscustomobject]@{ X = 0; Y = 0 }) +$this | Add-Member -MemberType NoteProperty -Force -Name '.Minimum' -Value ([pscustomobject]@{ X = 0; Y = 0 }) +$this | Add-Member -MemberType NoteProperty -Force -Name '.Maximum' -Value ([pscustomobject]@{ X = 0; Y = 0 }) +$this.ViewBox = 0 +return $this \ No newline at end of file diff --git a/Types/Turtle/DefaultDisplay.txt b/Types/Turtle/DefaultDisplay.txt new file mode 100644 index 0000000..4244bf1 --- /dev/null +++ b/Types/Turtle/DefaultDisplay.txt @@ -0,0 +1,2 @@ +Heading +Position diff --git a/Types/Turtle/FillColor.ps1 b/Types/Turtle/FillColor.ps1 new file mode 100644 index 0000000..c95bdae --- /dev/null +++ b/Types/Turtle/FillColor.ps1 @@ -0,0 +1,3 @@ +param($fill = 'transparent') +$this.Fill = $fill +return $this \ No newline at end of file diff --git a/Types/Turtle/Flower.ps1 b/Types/Turtle/Flower.ps1 new file mode 100644 index 0000000..9e96382 --- /dev/null +++ b/Types/Turtle/Flower.ps1 @@ -0,0 +1,28 @@ +<# +.SYNOPSIS + Draws a flower pattern. +.DESCRIPTION + Draws a flower pattern in turtle graphics. + + This pattern consists of a series of polygons and rotations to create a flower-like design. +.EXAMPLE + $turtle = New-Turtle + $turtle.Flower(100, 10, 5, 36) + $turtle.Pattern.Save("$pwd/FlowerPattern.svg") +.EXAMPLE + Move-Turtle Flower | + Save-Turtle ./FlowerSymbol.svg +#> +param( + [double]$Size = 100, + [double]$Rotation = 10, + [double]$SideCount = 4, + [double]$StepCount = 36 +) + +$null = foreach ($n in 1..$StepCount) { + $this.Polygon($Size, $SideCount) + $this.Rotate($Rotation) +} + +return $this \ No newline at end of file diff --git a/Types/Turtle/Forward.ps1 b/Types/Turtle/Forward.ps1 new file mode 100644 index 0000000..27f1c75 --- /dev/null +++ b/Types/Turtle/Forward.ps1 @@ -0,0 +1,14 @@ +param( +[double] +$Distance = 10 +) + +$x = $Distance * [math]::round([math]::cos($this.Heading * [Math]::PI / 180),15) +$y = $Distance * [math]::round([math]::sin($this.Heading * [Math]::PI / 180),15) +$this.Position = $x, $y +if ($This.IsPenDown) { + $this.Steps += " l $x $y" +} else { + $this.Steps += " m $x $y" +} +return $this \ No newline at end of file diff --git a/Types/Turtle/GoTo.ps1 b/Types/Turtle/GoTo.ps1 new file mode 100644 index 0000000..ca7692c --- /dev/null +++ b/Types/Turtle/GoTo.ps1 @@ -0,0 +1,29 @@ +<# +.SYNOPSIS + Go to a specific position. +.DESCRIPTION + Moves the turtle to a specific position. + + If the pen is down, it will draw a line to that position. +.EXAMPLE + Move-Turtle GoTo 10 10 | Move-Turtle Square 10 10 +#> +param( +# The X coordinate to move to. +[double] +$X, + +# The Y coordinate to move to. +[double] +$Y +) + +$deltaX = $x - $this.X +$deltaY = $y - $this.Y +if ($this.IsPenDown) { + $this.Steps += " l $deltaX $deltaY" +} else { + $this.Steps += " m $deltaX $deltaY" +} +$this.Position = $x, $y +return $this \ No newline at end of file diff --git a/Types/Turtle/GosperCurve.ps1 b/Types/Turtle/GosperCurve.ps1 new file mode 100644 index 0000000..74960d2 --- /dev/null +++ b/Types/Turtle/GosperCurve.ps1 @@ -0,0 +1,31 @@ +<# +.EXAMPLE + $turtle.Clear().GosperCurve().Pattern.Save("$pwd/GosperCurve.svg") +.EXAMPLE + $turtle.Clear() + $turtle.GosperCurve(20,1,60) + $turtle.PatternTransform = @{ + 'scale' = 0.5 + } + + $turtle.PatternAnimation = " + + + + " + $turtle.Pattern.Save("$pwd/GosperCurve2.svg") +#> +param( + [double]$Size = 10, + [int]$Order = 4, + [double]$Angle = 60 +) + +return $this.LSystem('A', @{ + A = 'A-B--B+A++AA+B-' + B = 'A-BB--B-A++A+B' +}, $Order, ([Ordered]@{ + '\+' = { $this.Rotate($Angle) } + '[AB]' = { $this.Forward($Size) } + '-' = { $this.Rotate($Angle * -1) } +})) diff --git a/Types/Turtle/HilbertCurve.ps1 b/Types/Turtle/HilbertCurve.ps1 new file mode 100644 index 0000000..4a521f5 --- /dev/null +++ b/Types/Turtle/HilbertCurve.ps1 @@ -0,0 +1,14 @@ +param( + [double]$Size = 10, + [int]$Order = 5, + [double]$Angle = 90 +) + +return $this.LSystem('A', @{ + A = '+BF-AFA-FB+' + B = '-AF+BFB+FA-' +}, $Order, @{ + 'F' = { $this.Forward($Size) } + '\+' = { $this.Rotate($Angle) } + '\-' = { $this.Rotate($Angle * -1) } +}) diff --git a/Types/Turtle/Jump.ps1 b/Types/Turtle/Jump.ps1 new file mode 100644 index 0000000..ff86d69 --- /dev/null +++ b/Types/Turtle/Jump.ps1 @@ -0,0 +1,22 @@ +<# +.SYNOPSIS + Jumps the turtle forward by a specified distance +.DESCRIPTION + Moves the turtle forward by the specified distance without drawing. + + Turtles may not be known for their jumping abilities, but they may surprise you! +.EXAMPLE + $turtle. + Clear(). + Rotate(45). + Forward(10). + Jump(20). + Forward(10). + Symbol.Save("$pwd/Jump.svg") +#> +param( +# The distance to jump forward +[double]$Distance +) + +$this.PenUp().Forward($Distance).PenDown() diff --git a/Types/Turtle/KochCurve.ps1 b/Types/Turtle/KochCurve.ps1 new file mode 100644 index 0000000..0b72d51 --- /dev/null +++ b/Types/Turtle/KochCurve.ps1 @@ -0,0 +1,12 @@ +param( + [double]$Size = 10, + [double]$Rotation = 90, + [int]$Order = 4 +) +return $this.LSystem('F', @{ + F = 'F+F-F-F+F' +}, $Order, [Ordered]@{ + '\+' = { $this.Rotate($Rotation) } + 'F' = { $this.Forward($Size) } + '\-' = { $this.Rotate($Rotation * -1) } +}) \ No newline at end of file diff --git a/Types/Turtle/KochIsland.ps1 b/Types/Turtle/KochIsland.ps1 new file mode 100644 index 0000000..0e1ebe1 --- /dev/null +++ b/Types/Turtle/KochIsland.ps1 @@ -0,0 +1,36 @@ +<# +.SYNOPSIS + Generates a Koch Island +.DESCRIPTION + Generates a Koch Island using turtle graphics. +.EXAMPLE + $turtle.KochIsland().Pattern.Save("$pwd/KochIsland.svg") +.EXAMPLE + $turtle.Clear() + $turtle.KochIsland(10,4) + $turtle.PatternTransform = @{ + 'scale' = 0.9 + } + $turtle.PatternAnimation = " + + + + + + " + $turtle.Pattern.Save("$pwd/KochIsland2.svg") +#> +param( + [double]$Size = 20, + [int]$Order = 3, + [double]$Angle = 90 +) + +return $this.LSystem('W', [Ordered]@{ + W = 'F+F+F+F' + F = 'F+F-F-FF+F+F-F' +}, $Order, [Ordered]@{ + '\+' = { $this.Rotate($Angle) } + '-' = { $this.Rotate($Angle * -1) } + '[FG]' = { $this.Forward($Size) } +}) diff --git a/Types/Turtle/KochSnowflake.ps1 b/Types/Turtle/KochSnowflake.ps1 new file mode 100644 index 0000000..5aba591 --- /dev/null +++ b/Types/Turtle/KochSnowflake.ps1 @@ -0,0 +1,34 @@ +<# +.SYNOPSIS + Generates a Koch Snowflake. +.DESCRIPTION + Generates a Koch Snowflake using turtle graphics. +.LINK + https://en.wikipedia.org/wiki/Koch_snowflake#Representation_as_Lindenmayer_system +.EXAMPLE + $turtle.KochSnowflake().Pattern.Save("$pwd/KochSnowflake.svg") +.EXAMPLE + $turtle.Clear() + $turtle.KochSnowflake(10,4) + $turtle.PatternTransform = @{ + 'scale' = 0.9 + } + $turtle.PatternAnimation = " + + + + " + $turtle.Pattern.Save("$pwd/KochSnowflake2.svg") +#> +param( + [double]$Size = 10, + [int]$Order = 4, + [double]$Rotation = 60 +) +return $this.LSystem('F--F--F ', @{ + F = 'F+F--F+F' +}, $Order, [Ordered]@{ + '\+' = { $this.Rotate($Rotation) } + 'F' = { $this.Forward($Size) } + '-' = { $this.Rotate($Rotation * -1) } +}) \ No newline at end of file diff --git a/Types/Turtle/LSystem.ps1 b/Types/Turtle/LSystem.ps1 new file mode 100644 index 0000000..72524ad --- /dev/null +++ b/Types/Turtle/LSystem.ps1 @@ -0,0 +1,162 @@ +<# +.SYNOPSIS + Draws a L-system pattern. +.DESCRIPTION + Generates a pattern using a L-system. + + The initial string (Axiom) is transformed according to the rules provided for a specified number of iterations. +.LINK + https://en.wikipedia.org/wiki/L-system +.EXAMPLE + # Box Fractal L-System + $Box = 'F-F-F-F' + $Fractal = 'F-F+F+F-F' + + $turtle.Clear().LSystem( + $Box, + [Ordered]@{ F = $Fractal }, + 3, + @{ + F = { $this.Forward(10) } + J = { $this.Jump(10) } + '\+' = { $this.Rotate(90) } + '-' = { $this.Rotate(-90) } + } + ).Pattern.Save("$pwd/BoxFractalLSystem.svg") +.EXAMPLE + # Fractal L-System + $Box = 'FFFF-FFFF-FFFF-FFFF' + $Fractal = 'F-F+F+F-F' + + $turtle.Clear().LSystem( + $Box, + [Ordered]@{ F = $Fractal }, + 4, + @{ + F = { $this.Forward(10) } + J = { $this.Jump(10) } + '\+' = { $this.Rotate(90) } + '-' = { $this.Rotate(-90) } + } + ).Symbol.Save("$pwd/FractalLSystem.svg") +.EXAMPLE + # Arrowhead Fractal L-System + $Box = 'FF-FF-FF' + $Fractal = 'F-F+F+F-F' + + + $turtle.Clear().LSystem( + $Box, + [Ordered]@{ F = $Fractal }, + 4, + @{ + F = { $this.Forward(10) } + J = { $this.Jump(10) } + '\+' = { $this.Rotate(90) } + '-' = { $this.Rotate(-90) } + } + ).Pattern.Save("$pwd/ArrowheadFractalLSystem.svg") +.EXAMPLE + # Tetroid LSystem + $turtle.Clear().LSystem( + 'F', + [Ordered]@{ F = 'F+F+F+F' + + '+JJJJ+' + + 'F+F+F+F' + + '++JJJJ' + + 'F+F+F+F' + + '++JJJJ' + + 'F+F+F+F' + + '++JJJJ' + + '-JJJJ' + }, + 3, + @{ + F = { $this.Forward(10) } + J = { $this.Jump(10) } + '\+' = { $this.Rotate(90) } + '-' = { $this.Rotate(-90) } + } + ).Pattern.Save("$pwd/TetroidLSystem.svg") + +.EXAMPLE + $turtle.Clear().LSystem( + 'F', + [Ordered]@{ F = ' +F+F+F+F +JJJJ+ F+F+F+F ++ JJJJ' }, + 3, + @{ + F = { $this.Forward(10) } + J = { $this.Jump(10) } + '\+' = { $this.Rotate(90) } + '-' = { $this.Rotate(-90) } + } + ).Pattern.Save("$pwd/LSystemCool1.svg") +.EXAMPLE + Move-Turtle LSystem F-F-F-F ([Ordered]@{F='F-F+F+F-F'}) 3 ( + [Ordered]@{ + F = { $this.Forward(10) } + J = { $this.Jump(10) } + '\+' = { $this.Rotate(90) } + '-' = { $this.Rotate(-90) } + } + ) + +#> +param( +[Alias('Start', 'StartString', 'Initiator')] +[string] +$Axiom, + +[Alias('Rules', 'ProductionRules')] +[Collections.IDictionary] +$Rule = [Ordered]@{}, + +[Alias('Iterations', 'Steps', 'IterationCount','StepCount')] +[int] +$N = 2, + +[Collections.IDictionary] +$Variable = @{} +) + +if ($n -lt 1) { return $Axiom} + +$currentState = "$Axiom" +$combinedPattern = "(?>$($Rule.Keys -join '|'))" +foreach ($iteration in 1..$n) { + $currentState = $currentState -replace $combinedPattern, { + $match = $_ + $matchingRule = $rule["$match"] + if ($matchingRule -is [ScriptBlock]) { + return "$(& $matchingRule $match)" + } else { + return $matchingRule + } + } +} + +$localReplacement = [Ordered]@{} +foreach ($key in $variable.Keys) { + $localReplacement[$key] = + if ($variable[$key] -is [ScriptBlock]) { + [ScriptBlock]::Create($variable[$key]) + } else { + $variable[$key] + } +} + +$finalState = $currentState +$null = foreach ($character in $finalState.ToCharArray()) { + foreach ($key in $Variable.Keys) { + if ($character -match $key) { + $action = $localReplacement[$key] + if ($action -is [ScriptBlock]) { + . $action $character + } else { + $action + } + } + } +} +return $this diff --git a/Types/Turtle/Left.ps1 b/Types/Turtle/Left.ps1 new file mode 100644 index 0000000..65ee909 --- /dev/null +++ b/Types/Turtle/Left.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Turns the turtle left +.DESCRIPTION + Turns the turtle left (counter-clockwise) by the specified angle. +#> +param( +[double]$Angle = 90 +) + +$this.Rotate($Angle * -1) \ No newline at end of file diff --git a/Types/Turtle/MooreCurve.ps1 b/Types/Turtle/MooreCurve.ps1 new file mode 100644 index 0000000..5216855 --- /dev/null +++ b/Types/Turtle/MooreCurve.ps1 @@ -0,0 +1,35 @@ +<# +.SYNOPSIS + Generates a Moore curve. +.DESCRIPTION + Generates a Moore curve using turtle graphics. +.LINK + https://en.wikipedia.org/wiki/Moore_curve +.EXAMPLE + $turtle = New-Turtle + $turtle.MooreCurve().Pattern.Save("$pwd/MooreCurvePattern.svg") +.EXAMPLE + Move-Turtle MooreCurve 15 5 | + Set-Turtle Stroke '#4488ff' | + Save-Turtle "./MooreCurve.svg" +#> +param( + [double]$Size = 10, + [int]$Order = 4, + [double]$Angle = 90 +) + + +return $this.LSystem( + 'LFL+F+LFL', + [Ordered]@{ + L = '-RF+LFL+FR-' + R = '+LF-RFR-FL+' + }, + 4, + @{ + F = { $this.Forward(10) } + '\+' = { $this.Rotate(90) } + '-' = { $this.Rotate(-90) } + } +) diff --git a/Types/Turtle/PeanoCurve.ps1 b/Types/Turtle/PeanoCurve.ps1 new file mode 100644 index 0000000..01061eb --- /dev/null +++ b/Types/Turtle/PeanoCurve.ps1 @@ -0,0 +1,29 @@ +<# +.SYNOPSIS + Generates a Peano curve. +.DESCRIPTION + Generates a Peano curve using turtle graphics. +.LINK + https://en.wikipedia.org/wiki/Peano_curve +.EXAMPLE + $turtle = New-Turtle + $turtle.PeanoCurve().Pattern.Save("$pwd/PeanoCurve.svg") +.EXAMPLE + Move-Turtle PeanoCurve 15 5 | + Set-Turtle Stroke '#4488ff' | + Save-Turtle "./PeanoCurve.svg" +#> +param( + [double]$Size = 10, + [int]$Order = 5, + [double]$Angle = 90 +) + +return $this.LSystem('X', @{ + X = 'XFYFX+F+YFXFY-F-XFYFX' + Y = 'YFXFY-F-XFYFX+F+YFXFY' +}, $Order, ([Ordered]@{ + '\+' = { $this.Rotate($Angle) } + '[F]' = { $this.Forward($Size) } + '\-' = { $this.Rotate($Angle * -1) } +})) diff --git a/Types/Turtle/PenColor.ps1 b/Types/Turtle/PenColor.ps1 new file mode 100644 index 0000000..da54c41 --- /dev/null +++ b/Types/Turtle/PenColor.ps1 @@ -0,0 +1,3 @@ +param($stroke = 'currentColor') +$this.Stroke = $stroke +return $this \ No newline at end of file diff --git a/Types/Turtle/PenDown.ps1 b/Types/Turtle/PenDown.ps1 new file mode 100644 index 0000000..617e781 --- /dev/null +++ b/Types/Turtle/PenDown.ps1 @@ -0,0 +1,2 @@ +$this.IsPenDown = $true +return $this diff --git a/Types/Turtle/PenUp.ps1 b/Types/Turtle/PenUp.ps1 new file mode 100644 index 0000000..653aee1 --- /dev/null +++ b/Types/Turtle/PenUp.ps1 @@ -0,0 +1,2 @@ +$this.IsPenDown = $false +return $this diff --git a/Types/Turtle/Polygon.ps1 b/Types/Turtle/Polygon.ps1 new file mode 100644 index 0000000..afff6fe --- /dev/null +++ b/Types/Turtle/Polygon.ps1 @@ -0,0 +1,10 @@ +param( + $Size = 100, + $SideCount = 6 +) + +$null = foreach ($n in 1..$SideCount) { + $this.Forward($Size) + $this.Rotate(360 / $SideCount) +} +return $this \ No newline at end of file diff --git a/Types/Turtle/Right.ps1 b/Types/Turtle/Right.ps1 new file mode 100644 index 0000000..c07ab84 --- /dev/null +++ b/Types/Turtle/Right.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Turns the turtle right +.DESCRIPTION + Turns the turtle right (clockwise) by the specified angle. +#> +param( +[double]$Angle = 90 +) + +$this.Rotate($Angle) \ No newline at end of file diff --git a/Types/Turtle/Rotate.ps1 b/Types/Turtle/Rotate.ps1 new file mode 100644 index 0000000..12c3da5 --- /dev/null +++ b/Types/Turtle/Rotate.ps1 @@ -0,0 +1,9 @@ +<# +.SYNOPSIS + Rotates the turtle. +.DESCRIPTION + Rotates the turtle by the specified angle. +#> +param([double]$Angle = 90) +$this.Heading += $Angle +return $this \ No newline at end of file diff --git a/Types/Turtle/SierpinskiArrowheadCurve.ps1 b/Types/Turtle/SierpinskiArrowheadCurve.ps1 new file mode 100644 index 0000000..42dc98a --- /dev/null +++ b/Types/Turtle/SierpinskiArrowheadCurve.ps1 @@ -0,0 +1,36 @@ +<# +.SYNOPSIS + Generates a Sierpinski Arrowhead Curve. +.DESCRIPTION + Generates a Sierpinski Arrowhead Curve using turtle graphics. +.LINK + https://en.wikipedia.org/wiki/Sierpi%C5%84ski_curve#Representation_as_Lindenmayer_system_2 +.EXAMPLE + $turtle.SierpinskiArrowheadCurve().Pattern.Save("$pwd/SierpinskiArrowhead.svg") +.EXAMPLE + $turtle.Clear() + $turtle.SierpinskiArrowheadCurve(10,4) + $turtle.PatternTransform = @{ + 'scale' = 0.9 + } + $turtle.PatternAnimation = " + + + + " + $turtle.Pattern.Save("$pwd/SierpinskiArrowhead2.svg") +#> + +param( + [double]$Size = 30, + [int]$Order = 8, + [double]$Angle = 60 +) +return $this.LSystem('XF', [Ordered]@{ + X = 'YF + XF + Y' + Y = 'XF - YF - X' +}, $Order, [Ordered]@{ + '\+' = { $this.Rotate($Angle) } + '-' = { $this.Rotate($Angle * -1) } + 'F' = { $this.Forward($Size) } +}) diff --git a/Types/Turtle/SierpinskiCurve.ps1 b/Types/Turtle/SierpinskiCurve.ps1 new file mode 100644 index 0000000..8a61244 --- /dev/null +++ b/Types/Turtle/SierpinskiCurve.ps1 @@ -0,0 +1,34 @@ +<# +.SYNOPSIS + Generates a Sierpinski Curve. +.DESCRIPTION + Generates a Sierpinski Curve using turtle graphics. +.LINK + https://en.wikipedia.org/wiki/Sierpi%C5%84ski_curve#Representation_as_Lindenmayer_system +.EXAMPLE + $turtle.SierpinskiCurve().Pattern.Save("$pwd/SierpinskiCurve.svg") +.EXAMPLE + $turtle.Clear() + $turtle.SierpinskiCurve(10,4) + $turtle.PatternTransform = @{ + 'scale' = 0.9 + } + $turtle.PatternAnimation = " + + + + " + $turtle.Pattern.Save("$pwd/SierpinskiCurve2.svg") +#> +param( + [double]$Size = 20, + [int]$Order = 4, + [double]$Angle = 45 +) +return $this.LSystem('F--XF--F--XF', [Ordered]@{ + X ='XF+G+XF--F--XF+G+X' +}, $Order, [Ordered]@{ + '\+' = { $this.Rotate($Angle) } + '-' = { $this.Rotate($Angle * -1) } + '[FG]' = { $this.Forward($Size) } +}) diff --git a/Types/Turtle/SierpinskiSquareCurve.ps1 b/Types/Turtle/SierpinskiSquareCurve.ps1 new file mode 100644 index 0000000..fe717d3 --- /dev/null +++ b/Types/Turtle/SierpinskiSquareCurve.ps1 @@ -0,0 +1,34 @@ +<# +.SYNOPSIS + Generates a Sierpinski Square Curve. +.DESCRIPTION + Generates a Sierpinski Square Curve using turtle graphics. +.LINK + https://en.wikipedia.org/wiki/Sierpi%C5%84ski_curve#Representation_as_Lindenmayer_system +.EXAMPLE + $turtle.SierpinskiSquareCurve().Pattern.Save("$pwd/SierpinskiSquareCurve.svg") +.EXAMPLE + $turtle.Clear() + $turtle.SierpinskiSquareCurve(10,4) + $turtle.PatternTransform = @{ + 'scale' = 0.9 + } + $turtle.PatternAnimation = " + + + + " + $turtle.Pattern.Save("$pwd/SierpinskiSquareCurve2.svg") +#> +param( + [double]$Size = 20, + [int]$Order = 5, + [double]$Angle = 90 +) +return $this.LSystem('X', [Ordered]@{ + X = 'XF-F+F-XF+F+XF-F+F-X' +}, $Order, [Ordered]@{ + '\+' = { $this.Rotate($Angle) } + '-' = { $this.Rotate($Angle * -1) } + '[FG]' = { $this.Forward($Size) } +}) diff --git a/Types/Turtle/SierpinskiTriangle.ps1 b/Types/Turtle/SierpinskiTriangle.ps1 new file mode 100644 index 0000000..c0e2805 --- /dev/null +++ b/Types/Turtle/SierpinskiTriangle.ps1 @@ -0,0 +1,37 @@ +<# +.SYNOPSIS + Generates a Sierpinski Triangle. +.DESCRIPTION + Generates a Sierpinski Triangle using turtle graphics. +.LINK + https://en.wikipedia.org/wiki/Sierpi%C5%84ski_triangle +.EXAMPLE + $turtle.SierpinskiTriangle().Pattern.Save("$pwd/SierpinskiTriangle.svg") +.EXAMPLE + $turtle.Clear() + $turtle.SierpinskiTriangle(10,6) + $turtle.PatternTransform = @{ + 'scale' = 0.9 + } + $turtle.PatternAnimation = " + + + + + + " + $turtle.Pattern.Save("$pwd/SierpinskiTriangle2.svg") +#> +param( + [double]$Size = 200, + [int]$Order = 2, + [double]$Angle = 120 +) +return $this.LSystem('F-G-G', [Ordered]@{ + F = 'F-G+F+G-F' + G = 'GG' +}, $Order, [Ordered]@{ + '\+' = { $this.Rotate($Angle) } + '-' = { $this.Rotate($Angle * -1) } + '[FG]' = { $this.Forward($Size) } +}) diff --git a/Types/Turtle/Square.ps1 b/Types/Turtle/Square.ps1 new file mode 100644 index 0000000..01a8a81 --- /dev/null +++ b/Types/Turtle/Square.ps1 @@ -0,0 +1,6 @@ +param([double]$Size = 50) +$null = foreach ($n in 1..4) { + $this.Forward($Size) + $this.Rotate(90) +} +return $this \ No newline at end of file diff --git a/Types/Turtle/Star.ps1 b/Types/Turtle/Star.ps1 new file mode 100644 index 0000000..84b929a --- /dev/null +++ b/Types/Turtle/Star.ps1 @@ -0,0 +1,20 @@ +<# +.SYNOPSIS + Draws a star pattern +.DESCRIPTION + Draws a star pattern with turtle graphics. +.EXAMPLE + $turtle = New-Turtle + $turtle.Star().Pattern.Save("$pwd/Star.svg") +.EXAMPLE + Move-Turtle Star | Save-Turtle "./Star.svg" +#> +param( + [double]$Size = 50, + [int]$Points = 5 +) +$Angle = 180 - (180 / $Points) +foreach ($n in 1..$Points) { + $this.Forward($Size) + $this.Rotate($Angle) +} diff --git a/Types/Turtle/Teleport.ps1 b/Types/Turtle/Teleport.ps1 new file mode 100644 index 0000000..da29419 --- /dev/null +++ b/Types/Turtle/Teleport.ps1 @@ -0,0 +1,23 @@ +<# +.SYNOPSIS + Teleports to a specific position. +.DESCRIPTION + Teleports the turtle to a specific position. +.EXAMPLE + Move-Turtle Teleport 5 5 | Move-Turtle Square 10 +#> +param( +# The X coordinate to move to. +[double] +$X, + +# The Y coordinate to move to. +[double] +$Y +) + +$deltaX = $x - $this.X +$deltaY = $y - $this.Y +$this.Steps += "m $deltaX $deltaY" +$this.Position = $x, $y +return $this \ No newline at end of file diff --git a/Types/Turtle/TerdragonCurve.ps1 b/Types/Turtle/TerdragonCurve.ps1 new file mode 100644 index 0000000..43a2408 --- /dev/null +++ b/Types/Turtle/TerdragonCurve.ps1 @@ -0,0 +1,37 @@ + +<# +.SYNOPSIS + Generates a Terdragon Curve. +.DESCRIPTION + Generates a Terdragon curve using turtle graphics. +.LINK + https://en.wikipedia.org/wiki/Dragon_curve#Terdragon +.EXAMPLE + $turtle.TerdragonCurve().Pattern.Save("$pwd/TerdragonCurve.svg") +.EXAMPLE + $turtle.Clear() + $turtle.TerdragonCurve(20,7,90) + $turtle.PatternTransform = @{ + 'scale' = 0.9 + 'rotate' = 45 + } + + $turtle.PatternAnimation = " + + + + " + $turtle.Pattern.Save("$pwd/TerdragonCurve2.svg") +#> +param( + [double]$Size = 20, + [int]$Order = 8, + [double]$Angle = 120 +) +return $this.LSystem('F', [Ordered]@{ + F = 'F+F-F' +}, $Order, [Ordered]@{ + '\+' = { $this.Rotate($Angle) } + '-' = { $this.Rotate($Angle * -1) } + '[F]' = { $this.Forward($Size) } +}) diff --git a/Types/Turtle/TwinDragonCurve.ps1 b/Types/Turtle/TwinDragonCurve.ps1 new file mode 100644 index 0000000..3712242 --- /dev/null +++ b/Types/Turtle/TwinDragonCurve.ps1 @@ -0,0 +1,38 @@ +<# +.SYNOPSIS + Generates a Twin Dragon Curve. +.DESCRIPTION + Generates a Twin Dragon Curve using turtle graphics. +.LINK + https://en.wikipedia.org/wiki/Dragon_curve#Twindragon +.EXAMPLE + $turtle.TwinDragonCurve().Pattern.Save("$pwd/TwinDragonCurve.svg") +.EXAMPLE + $turtle.Clear() + $turtle.TwinDragonCurve(20,7,90) + $turtle.PatternTransform = @{ + 'scale' = 0.9 + 'rotate' = 45 + } + + $turtle.PatternAnimation = " + + + + " + $turtle.Pattern.Save("$pwd/TwinDragonCurve2.svg") +#> + +param( + [double]$Size = 20, + [int]$Order = 6, + [double]$Angle = 90 +) +return $this.LSystem('FX+FX+', [Ordered]@{ + X = 'X+YF' + Y = 'FX-Y' +}, $Order, [Ordered]@{ + '\+' = { $this.Rotate($Angle) } + '-' = { $this.Rotate($Angle * -1) } + '[F]' = { $this.Forward($Size) } +}) diff --git a/Types/Turtle/get_AnimateMotion.ps1 b/Types/Turtle/get_AnimateMotion.ps1 new file mode 100644 index 0000000..c614938 --- /dev/null +++ b/Types/Turtle/get_AnimateMotion.ps1 @@ -0,0 +1,7 @@ +@("") -as [xml] \ No newline at end of file diff --git a/Types/Turtle/get_AnimateMotionDuration.ps1 b/Types/Turtle/get_AnimateMotionDuration.ps1 new file mode 100644 index 0000000..1be9c30 --- /dev/null +++ b/Types/Turtle/get_AnimateMotionDuration.ps1 @@ -0,0 +1,7 @@ +if ($this.'.AnimateMotionDuration') { + return $this.'.AnimateMotionDuration' +} +$thesePoints = $this.Points +if ($thesePoints.Length -eq 0) { + return "$(($thesePoints.Length / 2 / 10))s" +} diff --git a/Types/Turtle/get_BackgroundColor.ps1 b/Types/Turtle/get_BackgroundColor.ps1 new file mode 100644 index 0000000..fca0899 --- /dev/null +++ b/Types/Turtle/get_BackgroundColor.ps1 @@ -0,0 +1,5 @@ +param() + +if ($this.'.BackgroundColor') { + return $this.'.BackgroundColor' +} diff --git a/Types/Turtle/get_Canvas.ps1 b/Types/Turtle/get_Canvas.ps1 new file mode 100644 index 0000000..64ca199 --- /dev/null +++ b/Types/Turtle/get_Canvas.ps1 @@ -0,0 +1,37 @@ +<# +.SYNOPSIS + Gets a turtle canvas +.DESCRIPTION + Gets a turtle a canvas element. +#> + +@( + $viewBox = $this.ViewBox + $null, $null, $viewX, $viewY = $viewBox + "" + "" + + "" +) + diff --git a/Types/Turtle/get_ClipPath.ps1 b/Types/Turtle/get_ClipPath.ps1 new file mode 100644 index 0000000..3f70443 --- /dev/null +++ b/Types/Turtle/get_ClipPath.ps1 @@ -0,0 +1 @@ +"clip-path: path(`"$($this.PathData)`");" \ No newline at end of file diff --git a/Types/Turtle/get_DataURL.ps1 b/Types/Turtle/get_DataURL.ps1 new file mode 100644 index 0000000..4f7eb4e --- /dev/null +++ b/Types/Turtle/get_DataURL.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets the turtle data URL. +.DESCRIPTION + Gets the turtle symbol as a data URL. + + This can be used as an inline image in HTML, CSS, or Markdown. +#> +$thisSymbol = $this.Symbol +$b64 = [Convert]::ToBase64String($OutputEncoding.GetBytes($thisSymbol.outerXml)) +"data:image/svg+xml;base64,$b64" \ No newline at end of file diff --git a/Types/Turtle/get_Fill.ps1 b/Types/Turtle/get_Fill.ps1 new file mode 100644 index 0000000..0d456f4 --- /dev/null +++ b/Types/Turtle/get_Fill.ps1 @@ -0,0 +1,4 @@ +if ($this.'.Fill') { + return $this.'.Fill' +} +return 'transparent' \ No newline at end of file diff --git a/Types/Turtle/get_Heading.ps1 b/Types/Turtle/get_Heading.ps1 new file mode 100644 index 0000000..d2da812 --- /dev/null +++ b/Types/Turtle/get_Heading.ps1 @@ -0,0 +1,12 @@ +<# +.SYNOPSIS + Gets the turtle's heading. +.DESCRIPTION + Gets the current heading of the turtle. +#> +param() +if ($null -ne $this.'.TurtleHeading') { + return $this.'.TurtleHeading' +} +return 0 + diff --git a/Types/Turtle/get_IsPenDown.ps1 b/Types/Turtle/get_IsPenDown.ps1 new file mode 100644 index 0000000..c5fc897 --- /dev/null +++ b/Types/Turtle/get_IsPenDown.ps1 @@ -0,0 +1,2 @@ +if ($null -ne $this.'.IsPenDown') { return $this.'.IsPenDown' } +return $true diff --git a/Types/Turtle/get_JPEG.ps1 b/Types/Turtle/get_JPEG.ps1 new file mode 100644 index 0000000..d3214f7 --- /dev/null +++ b/Types/Turtle/get_JPEG.ps1 @@ -0,0 +1,45 @@ +$chromiumNames = 'chromium','chrome','msedge' +foreach ($browserName in $chromiumNames) { + $chromiumCommand = + $ExecutionContext.SessionState.InvokeCommand.GetCommand($browserName,'Application') + if (-not $chromiumCommand) { + $chromiumCommand = + Get-Process -Name $browserName -ErrorAction Ignore | + Select-Object -First 1 -ExpandProperty Path + } + if ($chromiumCommand) { break } +} +if (-not $chromiumCommand) { + Write-Error "No Chromium-based browser found. Please install one of: $($chromiumNames -join ', ')" + return +} + +$pngRasterizer = $this.Canvas -replace '/\*Insert-Post-Processing-Here\*/', @' + const dataUrl = await canvas.toDataURL('image/jpeg') + console.log(dataUrl) + + const newImage = document.createElement('img') + newImage.src = dataUrl + document.body.appendChild(newImage) +'@ + + +$appDataRoot = [Environment]::GetFolderPath("ApplicationData") +$appDataPath = Join-Path $appDataRoot 'Turtle' +$filePath = Join-Path $appDataPath 'Turtle.raster.html' +$null = New-Item -ItemType File -Force -Path $filePath -Value ( + $pngRasterizer -join [Environment]::NewLine +) +# $pngRasterizer > $filePath + +$headlessArguments = @( + '--headless', # run in headless mode + '--dump-dom', # dump the DOM to stdout + '--disable-gpu', # disable GPU acceleration + '--no-sandbox' # disable the sandbox if running in CI/CD +) + +$chromeOutput = & $chromiumCommand @headlessArguments "$filePath" | Out-String +if ($chromeOutput -match '[^"]+)') { + ,[Convert]::FromBase64String($matches.b64) +} diff --git a/Types/Turtle/get_Mask.ps1 b/Types/Turtle/get_Mask.ps1 new file mode 100644 index 0000000..29def88 --- /dev/null +++ b/Types/Turtle/get_Mask.ps1 @@ -0,0 +1,10 @@ +$segments = @( +"" + "" + "" + $this.Symbol.OuterXml -replace '\<\?[^\>]+\>' + "" + "" +"" +) +[xml]($segments -join '') \ No newline at end of file diff --git a/Types/Turtle/get_Maximum.ps1 b/Types/Turtle/get_Maximum.ps1 new file mode 100644 index 0000000..ac2c4fd --- /dev/null +++ b/Types/Turtle/get_Maximum.ps1 @@ -0,0 +1,12 @@ +<# +.SYNOPSIS + Gets the turtle maximum point. +.DESCRIPTION + Gets the maximum point reached by the turtle. + + Keeping track of this as we go is far more efficient than calculating it from the path. +#> +if ($this.'.Maximum') { + return $this.'.Maximum' +} +return ([pscustomobject]@{ X = 0; Y = 0 }) \ No newline at end of file diff --git a/Types/Turtle/get_Minimum.ps1 b/Types/Turtle/get_Minimum.ps1 new file mode 100644 index 0000000..fe607d7 --- /dev/null +++ b/Types/Turtle/get_Minimum.ps1 @@ -0,0 +1,4 @@ +if ($this.'.Minimum') { + return $this.'.Minimum' +} +return ([pscustomobject]@{ X = 0; Y = 0 }) \ No newline at end of file diff --git a/Types/Turtle/get_OffsetPath.ps1 b/Types/Turtle/get_OffsetPath.ps1 new file mode 100644 index 0000000..3f35d3b --- /dev/null +++ b/Types/Turtle/get_OffsetPath.ps1 @@ -0,0 +1 @@ +"offset-path: $($this.PathData);" \ No newline at end of file diff --git a/Types/Turtle/get_PNG.ps1 b/Types/Turtle/get_PNG.ps1 new file mode 100644 index 0000000..01159be --- /dev/null +++ b/Types/Turtle/get_PNG.ps1 @@ -0,0 +1,45 @@ +$chromiumNames = 'chromium','chrome','msedge' +foreach ($browserName in $chromiumNames) { + $chromiumCommand = + $ExecutionContext.SessionState.InvokeCommand.GetCommand($browserName,'Application') + if (-not $chromiumCommand) { + $chromiumCommand = + Get-Process -Name $browserName -ErrorAction Ignore | + Select-Object -First 1 -ExpandProperty Path + } + if ($chromiumCommand) { break } +} +if (-not $chromiumCommand) { + Write-Error "No Chromium-based browser found. Please install one of: $($chromiumNames -join ', ')" + return +} + +$pngRasterizer = $this.Canvas -replace '/\*Insert-Post-Processing-Here\*/', @' + const dataUrl = await canvas.toDataURL('image/png') + console.log(dataUrl) + + const newImage = document.createElement('img') + newImage.src = dataUrl + document.body.appendChild(newImage) +'@ + + +$appDataRoot = [Environment]::GetFolderPath("ApplicationData") +$appDataPath = Join-Path $appDataRoot 'Turtle' +$filePath = Join-Path $appDataPath 'Turtle.raster.html' +$null = New-Item -ItemType File -Force -Path $filePath -Value ( + $pngRasterizer -join [Environment]::NewLine +) +# $pngRasterizer > $filePath + +$headlessArguments = @( + '--headless', # run in headless mode + '--dump-dom', # dump the DOM to stdout + '--disable-gpu', # disable GPU acceleration + '--no-sandbox' # disable the sandbox if running in CI/CD +) + +$chromeOutput = & $chromiumCommand @headlessArguments "$filePath" | Out-String +if ($chromeOutput -match '[^"]+)') { + ,[Convert]::FromBase64String($matches.b64) +} diff --git a/Types/Turtle/get_PathAttribute.ps1 b/Types/Turtle/get_PathAttribute.ps1 new file mode 100644 index 0000000..38c9665 --- /dev/null +++ b/Types/Turtle/get_PathAttribute.ps1 @@ -0,0 +1,4 @@ +if ($this.'.PathAttribute') { + return $this.'.PathAttribute' +} +return [Ordered]@{} \ No newline at end of file diff --git a/Types/Turtle/get_PathClass.ps1 b/Types/Turtle/get_PathClass.ps1 new file mode 100644 index 0000000..ce9c91c --- /dev/null +++ b/Types/Turtle/get_PathClass.ps1 @@ -0,0 +1,2 @@ +if ($this.'.PathClass') { return $this.'.PathClass'} +return 'foreground-stroke' \ No newline at end of file diff --git a/Types/Turtle/get_PathData.ps1 b/Types/Turtle/get_PathData.ps1 new file mode 100644 index 0000000..e4c0097 --- /dev/null +++ b/Types/Turtle/get_PathData.ps1 @@ -0,0 +1,22 @@ +@( + @( + + if ($this.Start.X -and $this.Start.Y) { + "m $($this.Start.x) $($this.Start.y)" + } + else { + @("m" + if ($this.Minimum.X -lt 0) { + -1 * $this.Minimum.X + } else { + 0 + } + if ($this.Minimum.Y -lt 0) { + -1 * $this.Minimum.Y + } else { + 0 + }) -join ' ' + } + ) + $this.Steps + # @("m $($this.Start.x) $($this.Start.y) ") + $this.Steps +) -join ' ' \ No newline at end of file diff --git a/Types/Turtle/get_PathElement.ps1 b/Types/Turtle/get_PathElement.ps1 new file mode 100644 index 0000000..ae995b4 --- /dev/null +++ b/Types/Turtle/get_PathElement.ps1 @@ -0,0 +1,13 @@ +@( +"" +) -as [xml] \ No newline at end of file diff --git a/Types/Turtle/get_Pattern.ps1 b/Types/Turtle/get_Pattern.ps1 new file mode 100644 index 0000000..1f6d349 --- /dev/null +++ b/Types/Turtle/get_Pattern.ps1 @@ -0,0 +1,28 @@ +param() +$segments = @( +$viewBox = $this.ViewBox +$null, $null, $viewX, $viewY = $viewBox +"" +"" + "" + $(if ($this.PatternAnimation) { $this.PatternAnimation }) + $this.PathElement.OuterXml + "" +"" +$( + if ($this.BackgroundColor) { + "" + } +) +"" +"") + +$segments -join '' -as [xml] \ No newline at end of file diff --git a/Types/Turtle/get_PatternAnimation.ps1 b/Types/Turtle/get_PatternAnimation.ps1 new file mode 100644 index 0000000..b0a25b7 --- /dev/null +++ b/Types/Turtle/get_PatternAnimation.ps1 @@ -0,0 +1,3 @@ +if ($this.'.PatternAnimation') { + return $this.'.PatternAnimation' +} diff --git a/Types/Turtle/get_PatternDataURL.ps1 b/Types/Turtle/get_PatternDataURL.ps1 new file mode 100644 index 0000000..afb402b --- /dev/null +++ b/Types/Turtle/get_PatternDataURL.ps1 @@ -0,0 +1,11 @@ +<# +.SYNOPSIS + Gets the turtle pattern data URL. +.DESCRIPTION + Gets the turtle pattern as a data URL. + + This can be used as an inline image in HTML, CSS, or Markdown. +#> +$thisPattern = $this.Pattern +$b64 = [Convert]::ToBase64String($OutputEncoding.GetBytes($thisPattern.outerXml)) +"data:image/svg+xml;base64,$b64" \ No newline at end of file diff --git a/Types/Turtle/get_PatternMask.ps1 b/Types/Turtle/get_PatternMask.ps1 new file mode 100644 index 0000000..f7ed51c --- /dev/null +++ b/Types/Turtle/get_PatternMask.ps1 @@ -0,0 +1,10 @@ +$segments = @( +"" + "" + "" + $this.Pattern.OuterXml -replace '\<\?[^\>]+\>' + "" + "" +"" +) +[xml]($segments -join '') \ No newline at end of file diff --git a/Types/Turtle/get_PatternTransform.ps1 b/Types/Turtle/get_PatternTransform.ps1 new file mode 100644 index 0000000..ffff15c --- /dev/null +++ b/Types/Turtle/get_PatternTransform.ps1 @@ -0,0 +1,3 @@ +if ($this.'.PatternTransform') { + return $this.'.PatternTransform' +} diff --git a/Types/Turtle/get_Points.ps1 b/Types/Turtle/get_Points.ps1 new file mode 100644 index 0000000..4a8704c --- /dev/null +++ b/Types/Turtle/get_Points.ps1 @@ -0,0 +1 @@ +$this.Steps -replace '[\w-[\d\.E\-]]+' -split '\s+' -ne '' -as [double[]] \ No newline at end of file diff --git a/Types/Turtle/get_Position.ps1 b/Types/Turtle/get_Position.ps1 new file mode 100644 index 0000000..e5812e4 --- /dev/null +++ b/Types/Turtle/get_Position.ps1 @@ -0,0 +1,4 @@ +if (-not $this.'.Position') { + $this | Add-Member -MemberType NoteProperty -Force -Name '.Position' -Value [pscustomobject]@{ X = 0; Y = 0 } +} +return $this.'.Position' \ No newline at end of file diff --git a/Types/Turtle/get_SVG.ps1 b/Types/Turtle/get_SVG.ps1 new file mode 100644 index 0000000..4f2c4a0 --- /dev/null +++ b/Types/Turtle/get_SVG.ps1 @@ -0,0 +1,6 @@ +param() +@( +"" + $this.PathElement.OuterXml +"" +) -join '' -as [xml] \ No newline at end of file diff --git a/Types/Turtle/get_Steps.ps1 b/Types/Turtle/get_Steps.ps1 new file mode 100644 index 0000000..ac07da6 --- /dev/null +++ b/Types/Turtle/get_Steps.ps1 @@ -0,0 +1,4 @@ +if ($this.'.Steps') { + return $this.'.Steps' +} +return ,@() diff --git a/Types/Turtle/get_Stroke.ps1 b/Types/Turtle/get_Stroke.ps1 new file mode 100644 index 0000000..5b193d3 --- /dev/null +++ b/Types/Turtle/get_Stroke.ps1 @@ -0,0 +1,5 @@ +if ($this.'.Stroke') { + return $this.'.Stroke' +} else { + return 'currentcolor' +} \ No newline at end of file diff --git a/Types/Turtle/get_StrokeWidth.ps1 b/Types/Turtle/get_StrokeWidth.ps1 new file mode 100644 index 0000000..d2c8108 --- /dev/null +++ b/Types/Turtle/get_StrokeWidth.ps1 @@ -0,0 +1,5 @@ +if ($this.'.StrokeWidth') { + return $this.'.StrokeWidth' +} else { + return '0.25%' +} \ No newline at end of file diff --git a/Types/Turtle/get_Symbol.ps1 b/Types/Turtle/get_Symbol.ps1 new file mode 100644 index 0000000..1734fba --- /dev/null +++ b/Types/Turtle/get_Symbol.ps1 @@ -0,0 +1,28 @@ +<# +.SYNOPSIS + Gets the Turtle as a symbol. +.DESCRIPTION + Returns the turtle as an SVG symbol element, which can be used in other SVG files. + + Symbols allow a shape to be scaled and reused without having the duplicate the drawing commands. + + By default, this will return a SVG defining the symbol and using it to fill the viewport. +.EXAMPLE + Move-Turtle Flower | + Select-Object -ExpandProperty Symbol +#> +param() + +@( + "" + "" + $this.PathElement.OuterXml + "" + $( + if ($this.BackgroundColor) { + "" + } + ) + "" + "" +) -join '' -as [xml] \ No newline at end of file diff --git a/Types/Turtle/get_ViewBox.ps1 b/Types/Turtle/get_ViewBox.ps1 new file mode 100644 index 0000000..f508524 --- /dev/null +++ b/Types/Turtle/get_ViewBox.ps1 @@ -0,0 +1,8 @@ +if ($this.'.ViewBox') { return $this.'.ViewBox' } + +$viewX = $this.Maximum.X + ($this.Minimum.X * -1) +$viewY = $this.Maximum.Y + ($this.Minimum.Y * -1) + +return 0, 0, $viewX, $viewY + + diff --git a/Types/Turtle/get_WEBP.ps1 b/Types/Turtle/get_WEBP.ps1 new file mode 100644 index 0000000..915fdf5 --- /dev/null +++ b/Types/Turtle/get_WEBP.ps1 @@ -0,0 +1,45 @@ +$chromiumNames = 'chromium','chrome','msedge' +foreach ($browserName in $chromiumNames) { + $chromiumCommand = + $ExecutionContext.SessionState.InvokeCommand.GetCommand($browserName,'Application') + if (-not $chromiumCommand) { + $chromiumCommand = + Get-Process -Name $browserName -ErrorAction Ignore | + Select-Object -First 1 -ExpandProperty Path + } + if ($chromiumCommand) { break } +} +if (-not $chromiumCommand) { + Write-Error "No Chromium-based browser found. Please install one of: $($chromiumNames -join ', ')" + return +} + +$pngRasterizer = $this.Canvas -replace '/\*Insert-Post-Processing-Here\*/', @' + const dataUrl = await canvas.toDataURL('image/webp') + console.log(dataUrl) + + const newImage = document.createElement('img') + newImage.src = dataUrl + document.body.appendChild(newImage) +'@ + + +$appDataRoot = [Environment]::GetFolderPath("ApplicationData") +$appDataPath = Join-Path $appDataRoot 'Turtle' +$filePath = Join-Path $appDataPath 'Turtle.raster.html' +$null = New-Item -ItemType File -Force -Path $filePath -Value ( + $pngRasterizer -join [Environment]::NewLine +) +# $pngRasterizer > $filePath + +$headlessArguments = @( + '--headless', # run in headless mode + '--dump-dom', # dump the DOM to stdout + '--disable-gpu', # disable GPU acceleration + '--no-sandbox' # disable the sandbox if running in CI/CD +) + +$chromeOutput = & $chromiumCommand @headlessArguments "$filePath" | Out-String +if ($chromeOutput -match '[^"]+)') { + ,[Convert]::FromBase64String($matches.b64) +} diff --git a/Types/Turtle/get_X.ps1 b/Types/Turtle/get_X.ps1 new file mode 100644 index 0000000..57c91f0 --- /dev/null +++ b/Types/Turtle/get_X.ps1 @@ -0,0 +1 @@ +$this.Position.X \ No newline at end of file diff --git a/Types/Turtle/get_Y.ps1 b/Types/Turtle/get_Y.ps1 new file mode 100644 index 0000000..6ae9651 --- /dev/null +++ b/Types/Turtle/get_Y.ps1 @@ -0,0 +1 @@ +$this.Position.Y \ No newline at end of file diff --git a/Types/Turtle/set_AnimateMotionDuration.ps1 b/Types/Turtle/set_AnimateMotionDuration.ps1 new file mode 100644 index 0000000..20e0f68 --- /dev/null +++ b/Types/Turtle/set_AnimateMotionDuration.ps1 @@ -0,0 +1,14 @@ +param( +[PSObject] +$AnimateMotionDuration +) + +if ($AnimateMotionDuration -is [TimeSpan]) { + $AnimateMotionDuration = $AnimateMotionDuration.TotalSeconds + 's' +} + +if ($AnimateMotionDuration -is [int] -or $AnimateMotionDuration -is [double]) { + $AnimateMotionDuration = "${AnimateMotionDuration}s" +} + +$this | Add-Member -MemberType NoteProperty -Force -Name '.AnimateMotionDuration' -Value $AnimateMotionDuration diff --git a/Types/Turtle/set_BackgroundColor.ps1 b/Types/Turtle/set_BackgroundColor.ps1 new file mode 100644 index 0000000..61dc7c0 --- /dev/null +++ b/Types/Turtle/set_BackgroundColor.ps1 @@ -0,0 +1,6 @@ +param( +[PSObject] +$value +) + +$this | Add-Member NoteProperty -Name '.BackgroundColor' -Value $value -Force diff --git a/Types/Turtle/set_Fill.ps1 b/Types/Turtle/set_Fill.ps1 new file mode 100644 index 0000000..9d8588c --- /dev/null +++ b/Types/Turtle/set_Fill.ps1 @@ -0,0 +1,9 @@ +param( + [string]$Fill = 'transparent' +) + +if (-not $this.'.Fill') { + $this | Add-Member -MemberType NoteProperty -Name '.Fill' -Value $Fill -Force +} else { + $this.'.Fill' = $Fill +} \ No newline at end of file diff --git a/Types/Turtle/set_Heading.ps1 b/Types/Turtle/set_Heading.ps1 new file mode 100644 index 0000000..48a6bd3 --- /dev/null +++ b/Types/Turtle/set_Heading.ps1 @@ -0,0 +1,24 @@ +<# +.SYNOPSIS + Sets the turtle's heading. +.DESCRIPTION + Sets the turtle's heading. + + This is one of two key properties of the turtle, the other being its position. +#> +param( +# The new turtle heading. +[double] +$Heading +) + +if ($this -and -not $this.psobject.properties['.TurtleHeading']) { + $this.psobject.properties.add([PSNoteProperty]::new('.TurtleHeading', 0), $false) +} +$this.'.TurtleHeading' = $Heading + +# $this.psobject.properties.add([PSNoteProperty]::new('.TurtleHeading', $Heading), $false) +# $this | Add-Member -MemberType NoteProperty -Force -Name '.TurtleHeading' -Value $Heading +if ($VerbosePreference -ne 'SilentlyContinue') { + Write-Verbose "Heading to $Heading" +} \ No newline at end of file diff --git a/Types/Turtle/set_IsPenDown.ps1 b/Types/Turtle/set_IsPenDown.ps1 new file mode 100644 index 0000000..6f6a3de --- /dev/null +++ b/Types/Turtle/set_IsPenDown.ps1 @@ -0,0 +1,11 @@ +param( +[bool] +$IsDown +) + +$this | + Add-Member -MemberType NoteProperty -Force -Name '.IsPenDown' -Value $IsDown + +if ($VerbosePreference -ne 'SilentlyContinue') { + Write-Verbose "Turtle is now $($IsDown ? 'down' : 'up')" +} \ No newline at end of file diff --git a/Types/Turtle/set_PathAttribute.ps1 b/Types/Turtle/set_PathAttribute.ps1 new file mode 100644 index 0000000..cf6d406 --- /dev/null +++ b/Types/Turtle/set_PathAttribute.ps1 @@ -0,0 +1,11 @@ +param( +[Collections.IDictionary] +$PathAttribute = [Ordered]@{} +) + +if (-not $this.'.PathAttribute') { + $this | Add-Member -MemberType NoteProperty -Name '.PathAttribute' -Value ([Ordered]@{}) -Force +} +foreach ($key in $PathAttribute.Keys) { + $this.'.PathAttribute'[$key] = $PathAttribute[$key] +} \ No newline at end of file diff --git a/Types/Turtle/set_PathClass.ps1 b/Types/Turtle/set_PathClass.ps1 new file mode 100644 index 0000000..ffdd0b1 --- /dev/null +++ b/Types/Turtle/set_PathClass.ps1 @@ -0,0 +1,13 @@ +<# +.SYNOPSIS + Sets the turtle path class +.DESCRIPTION + Sets the css classes that apply to the turtle path. + + This property will rarely be set directly, but can be handy for integrating turtle graphics into custom pages. +#> +param( +$PathClass +) + +$this | Add-Member -MemberType NoteProperty -Force -Name '.PathClass' -Value @($PathClass) diff --git a/Types/Turtle/set_PatternAnimation.ps1 b/Types/Turtle/set_PatternAnimation.ps1 new file mode 100644 index 0000000..21d3420 --- /dev/null +++ b/Types/Turtle/set_PatternAnimation.ps1 @@ -0,0 +1,30 @@ +param( +[PSObject] +$PatternAnimation +) + +$newAnimation = @(foreach ($animation in $PatternAnimation) { + if ($animation -is [Collections.IDictionary]) { + $animationCopy = [Ordered]@{} + $animation + if (-not $animationCopy['attributeType']) { + $animationCopy['attributeType'] = 'XML' + } + if (-not $animationCopy['attributeName']) { + $animationCopy['attributeName'] = 'patternTransform' + } + if ($animationCopy.values -is [object[]]) { + $animationCopy['values'] = $animationCopy['values'] -join ';' + } + + "" + } + if ($animation -is [string]) { + $animation + } +}) + +$this | Add-Member -MemberType NoteProperty -Force -Name '.PatternAnimation' -Value $newAnimation diff --git a/Types/Turtle/set_PatternTransform.ps1 b/Types/Turtle/set_PatternTransform.ps1 new file mode 100644 index 0000000..931f43e --- /dev/null +++ b/Types/Turtle/set_PatternTransform.ps1 @@ -0,0 +1,6 @@ +param( +[Collections.IDictionary] +$PatternTransform +) + +$this | Add-Member -MemberType NoteProperty -Force -Name '.PatternTransform' -Value $PatternTransform \ No newline at end of file diff --git a/Types/Turtle/set_Position.ps1 b/Types/Turtle/set_Position.ps1 new file mode 100644 index 0000000..de3749a --- /dev/null +++ b/Types/Turtle/set_Position.ps1 @@ -0,0 +1,26 @@ +param([double[]]$xy) +$x, $y = $xy +if (-not $this.'.Position') { + $this | Add-Member -MemberType NoteProperty -Force -Name '.Position' -Value ([pscustomobject]@{ X = 0; Y = 0 }) +} +$this.'.Position'.X += $x +$this.'.Position'.Y += $y +$posX, $posY = $this.'.Position'.X, $this.'.Position'.Y +if (-not $this.'.Minimum') { + $this | Add-Member -MemberType NoteProperty -Force -Name '.Minimum' -Value ([pscustomobject]@{ X = 0; Y = 0 }) +} +if (-not $this.'.Maximum') { + $this | Add-Member -MemberType NoteProperty -Force -Name '.Maximum' -Value ([pscustomobject]@{ X = 0; Y = 0 }) +} +if ($posX -lt $this.'.Minimum'.X) { + $this.'.Minimum'.X = $posX +} +if ($posY -lt $this.'.Minimum'.Y) { + $this.'.Minimum'.Y = $posY +} +if ($posX -gt $this.'.Maximum'.X) { + $this.'.Maximum'.X = $posX +} +if ($posY -gt $this.'.Maximum'.Y) { + $this.'.Maximum'.Y = $posY +} \ No newline at end of file diff --git a/Types/Turtle/set_Steps.ps1 b/Types/Turtle/set_Steps.ps1 new file mode 100644 index 0000000..2e3270e --- /dev/null +++ b/Types/Turtle/set_Steps.ps1 @@ -0,0 +1,14 @@ +<# +.SYNOPSIS + Sets the steps of the turtle. +.DESCRIPTION + Sets the steps of the turtle to the specified array of strings. + + This property will rarely be set directly, but will be updated every time the turtle moves. +#> +param( +[string[]] +$Steps +) + +$this | Add-Member -MemberType NoteProperty -Force -Name '.Steps' -Value @($Steps) diff --git a/Types/Turtle/set_Stroke.ps1 b/Types/Turtle/set_Stroke.ps1 new file mode 100644 index 0000000..7a6b9d9 --- /dev/null +++ b/Types/Turtle/set_Stroke.ps1 @@ -0,0 +1,3 @@ +param([string]$value) + +$this | Add-Member -MemberType NoteProperty -Force -Name '.Stroke' -Value $value \ No newline at end of file diff --git a/Types/Turtle/set_StrokeWidth.ps1 b/Types/Turtle/set_StrokeWidth.ps1 new file mode 100644 index 0000000..d2eab9c --- /dev/null +++ b/Types/Turtle/set_StrokeWidth.ps1 @@ -0,0 +1,3 @@ +param([string]$value) + +$this | Add-Member -MemberType NoteProperty -Force -Name '.StrokeWidth' -Value $value \ No newline at end of file diff --git a/Types/Turtle/set_ViewBox.ps1 b/Types/Turtle/set_ViewBox.ps1 new file mode 100644 index 0000000..850403d --- /dev/null +++ b/Types/Turtle/set_ViewBox.ps1 @@ -0,0 +1,32 @@ +param( +[double[]] +$viewBox +) + +if ($viewBox.Length -gt 4) { + $viewBox = $viewBox[0..3] +} +if ($viewBox.Length -lt 4) { + if ($viewBox.Length -eq 3) { + $viewBox = $viewBox[0], $viewBox[1], $viewBox[2],$viewBox[2] + } + if ($viewBox.Length -eq 2) { + $viewBox = 0,0, $viewBox[0], $viewBox[1] + } + if ($viewBox.Length -eq 1) { + $viewBox = 0,0, $viewBox[0], $viewBox[0] + } +} + +if ($viewBox[0] -eq 0 -and + $viewBox[1] -eq 0 -and + $viewBox[2] -eq 0 -and + $viewBox[3] -eq 0 +) { + $viewX = $this.Maximum.X + ($this.Minimum.X * -1) + $viewY = $this.Maximum.Y + ($this.Minimum.Y * -1) + $this.psobject.Properties.Remove('.ViewBox') + return +} + +$this | Add-Member -MemberType NoteProperty -Force -Name '.ViewBox' -Value $viewBox diff --git a/Types/Turtle/xcor.ps1 b/Types/Turtle/xcor.ps1 new file mode 100644 index 0000000..bbd320a --- /dev/null +++ b/Types/Turtle/xcor.ps1 @@ -0,0 +1 @@ +return $this.Position.X \ No newline at end of file diff --git a/Types/Turtle/ycor.ps1 b/Types/Turtle/ycor.ps1 new file mode 100644 index 0000000..b9723d2 --- /dev/null +++ b/Types/Turtle/ycor.ps1 @@ -0,0 +1 @@ +return $this.Position.Y \ No newline at end of file diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..7553b69 --- /dev/null +++ b/action.yml @@ -0,0 +1,444 @@ + +name: TurtlePower +description: Turtles in a PowerShell +inputs: + Run: + required: false + description: | + A PowerShell Script that uses Turtle. + Any files outputted from the script will be added to the repository. + If those files have a .Message attached to them, they will be committed with that message. + SkipScriptFile: + required: false + description: 'If set, will not process any files named *.Turtle.ps1' + InstallModule: + required: false + description: A list of modules to be installed from the PowerShell gallery before scripts run. + CommitMessage: + required: false + description: If provided, will commit any remaining changes made to the workspace with this commit message. + TargetBranch: + required: false + description: | + If provided, will checkout a new branch before making the changes. + If not provided, will use the current branch. + ActionScript: + required: false + description: The name of one or more scripts to run, from this action's path. + GitHubToken: + required: false + default: '{{ secrets.GITHUB_TOKEN }}' + description: The github token to use for requests. + UserEmail: + required: false + description: The user email associated with a git commit. If this is not provided, it will be set to the username@noreply.github.com. + UserName: + required: false + description: The user name associated with a git commit. + NoPush: + required: false + description: | + If set, will not push any changes made to the repository. + (they will still be committed unless `-NoCommit` is passed) + NoCommit: + required: false + description: | + If set, will not commit any changes made to the repository. + (this also implies `-NoPush`) +branding: + icon: chevron-right + color: blue +runs: + using: composite + steps: + - name: TurtleAction + id: TurtleAction + shell: pwsh + env: + Run: ${{inputs.Run}} + UserName: ${{inputs.UserName}} + GitHubToken: ${{inputs.GitHubToken}} + NoPush: ${{inputs.NoPush}} + ActionScript: ${{inputs.ActionScript}} + UserEmail: ${{inputs.UserEmail}} + CommitMessage: ${{inputs.CommitMessage}} + TargetBranch: ${{inputs.TargetBranch}} + InstallModule: ${{inputs.InstallModule}} + SkipScriptFile: ${{inputs.SkipScriptFile}} + NoCommit: ${{inputs.NoCommit}} + run: | + $Parameters = @{} + $Parameters.Run = ${env:Run} + $Parameters.SkipScriptFile = ${env:SkipScriptFile} + $Parameters.SkipScriptFile = $parameters.SkipScriptFile -match 'true'; + $Parameters.InstallModule = ${env:InstallModule} + $Parameters.InstallModule = $parameters.InstallModule -split ';' -replace '^[''"]' -replace '[''"]$' + $Parameters.CommitMessage = ${env:CommitMessage} + $Parameters.TargetBranch = ${env:TargetBranch} + $Parameters.ActionScript = ${env:ActionScript} + $Parameters.ActionScript = $parameters.ActionScript -split ';' -replace '^[''"]' -replace '[''"]$' + $Parameters.GitHubToken = ${env:GitHubToken} + $Parameters.UserEmail = ${env:UserEmail} + $Parameters.UserName = ${env:UserName} + $Parameters.NoPush = ${env:NoPush} + $Parameters.NoPush = $parameters.NoPush -match 'true'; + $Parameters.NoCommit = ${env:NoCommit} + $Parameters.NoCommit = $parameters.NoCommit -match 'true'; + foreach ($k in @($parameters.Keys)) { + if ([String]::IsNullOrEmpty($parameters[$k])) { + $parameters.Remove($k) + } + } + Write-Host "::debug:: TurtleAction $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {<# + .Synopsis + GitHub Action for Turtle + .Description + GitHub Action for Turtle. This will: + + * Import Turtle + * If `-Run` is provided, run that script + * Otherwise, unless `-SkipScriptFile` is passed, run all *.Turtle.ps1 files beneath the workflow directory + * If any `-ActionScript` was provided, run scripts from the action path that match a wildcard pattern. + + If you will be making changes using the GitHubAPI, you should provide a -GitHubToken + If none is provided, and ENV:GITHUB_TOKEN is set, this will be used instead. + Any files changed can be outputted by the script, and those changes can be checked back into the repo. + Make sure to use the "persistCredentials" option with checkout. + #> + + param( + # A PowerShell Script that uses Turtle. + # Any files outputted from the script will be added to the repository. + # If those files have a .Message attached to them, they will be committed with that message. + [string] + $Run, + + # If set, will not process any files named *.Turtle.ps1 + [switch] + $SkipScriptFile, + + # A list of modules to be installed from the PowerShell gallery before scripts run. + [string[]] + $InstallModule, + + # If provided, will commit any remaining changes made to the workspace with this commit message. + [string] + $CommitMessage, + + # If provided, will checkout a new branch before making the changes. + # If not provided, will use the current branch. + [string] + $TargetBranch, + + # The name of one or more scripts to run, from this action's path. + [string[]] + $ActionScript, + + # The github token to use for requests. + [string] + $GitHubToken = '{{ secrets.GITHUB_TOKEN }}', + + # The user email associated with a git commit. If this is not provided, it will be set to the username@noreply.github.com. + [string] + $UserEmail, + + # The user name associated with a git commit. + [string] + $UserName, + + # If set, will not push any changes made to the repository. + # (they will still be committed unless `-NoCommit` is passed) + [switch] + $NoPush, + + # If set, will not commit any changes made to the repository. + # (this also implies `-NoPush`) + [switch] + $NoCommit + ) + + $ErrorActionPreference = 'continue' + "::group::Parameters" | Out-Host + [PSCustomObject]$PSBoundParameters | Format-List | Out-Host + "::endgroup::" | Out-Host + + $gitHubEventJson = [IO.File]::ReadAllText($env:GITHUB_EVENT_PATH) + $gitHubEvent = + if ($env:GITHUB_EVENT_PATH) { + $gitHubEventJson | ConvertFrom-Json + } else { $null } + "::group::Parameters" | Out-Host + $gitHubEvent | Format-List | Out-Host + "::endgroup::" | Out-Host + + + $anyFilesChanged = $false + $ActionModuleName = 'Turtle' + $actorInfo = $null + + + $checkDetached = git symbolic-ref -q HEAD + if ($LASTEXITCODE) { + "::warning::On detached head, skipping action" | Out-Host + exit 0 + } + + function InstallActionModule { + param([string]$ModuleToInstall) + $moduleInWorkspace = Get-ChildItem -Path $env:GITHUB_WORKSPACE -Recurse -File | + Where-Object Name -eq "$($moduleToInstall).psd1" | + Where-Object { + $(Get-Content $_.FullName -Raw) -match 'ModuleVersion' + } + if (-not $moduleInWorkspace) { + $availableModules = Get-Module -ListAvailable + if ($availableModules.Name -notcontains $moduleToInstall) { + Install-Module $moduleToInstall -Scope CurrentUser -Force -AcceptLicense -AllowClobber + } + Import-Module $moduleToInstall -Force -PassThru | Out-Host + } else { + Import-Module $moduleInWorkspace.FullName -Force -PassThru | Out-Host + } + } + function ImportActionModule { + #region -InstallModule + if ($InstallModule) { + "::group::Installing Modules" | Out-Host + foreach ($moduleToInstall in $InstallModule) { + InstallActionModule -ModuleToInstall $moduleToInstall + } + "::endgroup::" | Out-Host + } + #endregion -InstallModule + + if ($env:GITHUB_ACTION_PATH) { + $LocalModulePath = Join-Path $env:GITHUB_ACTION_PATH "$ActionModuleName.psd1" + if (Test-path $LocalModulePath) { + Import-Module $LocalModulePath -Force -PassThru | Out-String + } else { + throw "Module '$ActionModuleName' not found" + } + } elseif (-not (Get-Module $ActionModuleName)) { + throw "Module '$ActionModuleName' not found" + } + + "::notice title=ModuleLoaded::$ActionModuleName Loaded from Path - $($LocalModulePath)" | Out-Host + if ($env:GITHUB_STEP_SUMMARY) { + "# $($ActionModuleName)" | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + } + function InitializeAction { + #region Custom + #endregion Custom + + # Configure git based on the $env:GITHUB_ACTOR + if (-not $UserName) { $UserName = $env:GITHUB_ACTOR } + if (-not $actorID) { $actorID = $env:GITHUB_ACTOR_ID } + $actorInfo = + if ($GitHubToken -notmatch '^\{{2}' -and $GitHubToken -notmatch '\}{2}$') { + Invoke-RestMethod -Uri "https://api.github.com/user/$actorID" -Headers @{ Authorization = "token $GitHubToken" } + } else { + Invoke-RestMethod -Uri "https://api.github.com/user/$actorID" + } + + if (-not $UserEmail) { $UserEmail = "$UserName@noreply.github.com" } + git config --global user.email $UserEmail + git config --global user.name $actorInfo.name + + # Pull down any changes + git pull | Out-Host + + if ($TargetBranch) { + "::notice title=Expanding target branch string $targetBranch" | Out-Host + $TargetBranch = $ExecutionContext.SessionState.InvokeCommand.ExpandString($TargetBranch) + "::notice title=Checking out target branch::$targetBranch" | Out-Host + git checkout -b $TargetBranch | Out-Host + git pull | Out-Host + } + } + + function InvokeActionModule { + $myScriptStart = [DateTime]::Now + $myScript = $ExecutionContext.SessionState.PSVariable.Get("Run").Value + if ($myScript) { + Invoke-Expression -Command $myScript | + . ProcessOutput | + Out-Host + return + } + $myScriptTook = [Datetime]::Now - $myScriptStart + $MyScriptFilesStart = [DateTime]::Now + + $myScriptList = @() + $shouldSkip = $ExecutionContext.SessionState.PSVariable.Get("SkipScriptFile").Value + if ($shouldSkip) { + return + } + $scriptFiles = @( + Get-ChildItem -Recurse -Path $env:GITHUB_WORKSPACE | + Where-Object Name -Match "\.$($ActionModuleName)\.ps1$" + if ($ActionScript) { + if ($ActionScript -match '^\s{0,}/' -and $ActionScript -match '/\s{0,}$') { + $ActionScriptPattern = $ActionScript.Trim('/').Trim() -as [regex] + if ($ActionScriptPattern) { + $ActionScriptPattern = [regex]::new($ActionScript.Trim('/').Trim(), 'IgnoreCase,IgnorePatternWhitespace', [timespan]::FromSeconds(0.5)) + Get-ChildItem -Recurse -Path $env:GITHUB_ACTION_PATH | + Where-Object { $_.Name -Match "\.$($ActionModuleName)\.ps1$" -and $_.FullName -match $ActionScriptPattern } + } + } else { + Get-ChildItem -Recurse -Path $env:GITHUB_ACTION_PATH | + Where-Object Name -Match "\.$($ActionModuleName)\.ps1$" | + Where-Object FullName -Like $ActionScript + } + } + ) | Select-Object -Unique + $scriptFiles | + ForEach-Object -Begin { + if ($env:GITHUB_STEP_SUMMARY) { + "## $ActionModuleName Scripts" | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + } -Process { + $myScriptList += $_.FullName.Replace($env:GITHUB_WORKSPACE, '').TrimStart('/') + $myScriptCount++ + $scriptFile = $_ + if ($env:GITHUB_STEP_SUMMARY) { + "### $($scriptFile.Fullname -replace [Regex]::Escape($env:GITHUB_WORKSPACE))" | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + $scriptCmd = $ExecutionContext.SessionState.InvokeCommand.GetCommand($scriptFile.FullName, 'ExternalScript') + foreach ($requiredModule in $CommandInfo.ScriptBlock.Ast.ScriptRequirements.RequiredModules) { + if ($requiredModule.Name -and + (-not $requiredModule.MaximumVersion) -and + (-not $requiredModule.RequiredVersion) + ) { + InstallActionModule $requiredModule.Name + } + } + Push-Location $scriptFile.Directory.Fullname + $scriptFileOutputs = . $scriptCmd + $scriptFileOutputs | + . ProcessOutput | + Out-Host + Pop-Location + } + + $MyScriptFilesTook = [Datetime]::Now - $MyScriptFilesStart + $SummaryOfMyScripts = "$myScriptCount $ActionModuleName scripts took $($MyScriptFilesTook.TotalSeconds) seconds" + $SummaryOfMyScripts | + Out-Host + if ($env:GITHUB_STEP_SUMMARY) { + $SummaryOfMyScripts | + Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + #region Custom + #endregion Custom + } + + function OutError { + $anyRuntimeExceptions = $false + foreach ($err in $error) { + $errParts = @( + "::error " + @( + if ($err.InvocationInfo.ScriptName) { + "file=$($err.InvocationInfo.ScriptName)" + } + if ($err.InvocationInfo.ScriptLineNumber -ge 1) { + "line=$($err.InvocationInfo.ScriptLineNumber)" + if ($err.InvocationInfo.OffsetInLine -ge 1) { + "col=$($err.InvocationInfo.OffsetInLine)" + } + } + if ($err.CategoryInfo.Activity) { + "title=$($err.CategoryInfo.Activity)" + } + ) -join ',' + "::" + $err.Exception.Message + if ($err.CategoryInfo.Category -eq 'OperationStopped' -and + $err.CategoryInfo.Reason -eq 'RuntimeException') { + $anyRuntimeExceptions = $true + } + ) -join '' + $errParts | Out-Host + if ($anyRuntimeExceptions) { + exit 1 + } + } + } + + function PushActionOutput { + if ($anyFilesChanged) { + "::notice::$($anyFilesChanged) Files Changed" | Out-Host + } + if ($CommitMessage -or $anyFilesChanged) { + if ($CommitMessage) { + Get-ChildItem $env:GITHUB_WORKSPACE -Recurse | + ForEach-Object { + $gitStatusOutput = git status $_.Fullname -s + if ($gitStatusOutput) { + git add $_.Fullname + } + } + + git commit -m $ExecutionContext.SessionState.InvokeCommand.ExpandString($CommitMessage) + } + + $checkDetached = git symbolic-ref -q HEAD + if (-not $LASTEXITCODE -and -not $NoPush -and -not $noCommit) { + if ($TargetBranch -and $anyFilesChanged) { + "::notice::Pushing Changes to $targetBranch" | Out-Host + git push --set-upstream origin $TargetBranch + } elseif ($anyFilesChanged) { + "::notice::Pushing Changes" | Out-Host + git push + } + "Git Push Output: $($gitPushed | Out-String)" + } else { + "::notice::Not pushing changes (on detached head)" | Out-Host + $LASTEXITCODE = 0 + exit 0 + } + } + } + + filter ProcessOutput { + $out = $_ + $outItem = Get-Item -Path $out -ErrorAction Ignore + if (-not $outItem -and $out -is [string]) { + $out | Out-Host + if ($env:GITHUB_STEP_SUMMARY) { + "> $out" | Out-File -Append -FilePath $env:GITHUB_STEP_SUMMARY + } + return + } + $fullName, $shouldCommit = + if ($out -is [IO.FileInfo]) { + $out.FullName, (git status $out.Fullname -s) + } elseif ($outItem) { + $outItem.FullName, (git status $outItem.Fullname -s) + } + if ($shouldCommit -and -not $NoCommit) { + "$fullName has changed, and should be committed" | Out-Host + git add $fullName + if ($out.Message) { + git commit -m "$($out.Message)" | Out-Host + } elseif ($out.CommitMessage) { + git commit -m "$($out.CommitMessage)" | Out-Host + } elseif ($gitHubEvent.head_commit.message) { + git commit -m "$($gitHubEvent.head_commit.message)" | Out-Host + } + $anyFilesChanged = $true + } + $out + } + + . ImportActionModule + . InitializeAction + . InvokeActionModule + . PushActionOutput + . OutError} @Parameters +