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/BuildFont.yml b/.github/workflows/BuildFont.yml new file mode 100644 index 0000000..45d8efc --- /dev/null +++ b/.github/workflows/BuildFont.yml @@ -0,0 +1,505 @@ + +name: Build Font 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@v2 + - 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 + + "::set-output name=TotalCount::$($result.TotalCount)", + "::set-output name=PassedCount::$($result.PassedCount)", + "::set-output name=FailedCount::$($result.FailedCount)" | Out-Host + 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 + BuildFont: + runs-on: ubuntu-latest + if: ${{ success() }} + steps: + - name: Check out repository + uses: actions/checkout@main + - name: UseEZOut + uses: StartAutomating/EZOut@master + - name: Run Action (on branch) + if: ${{github.ref_name != 'main'}} + uses: ./ + id: Action +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} diff --git a/Build/Font.GitHubAction.PSDevOps.ps1 b/Build/Font.GitHubAction.PSDevOps.ps1 new file mode 100644 index 0000000..0409c50 --- /dev/null +++ b/Build/Font.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 "BuildFont" -Description 'Turtles in a PowerShell' -Action FontAction -Icon type -OutputPath .\action.yml + +Pop-Location \ No newline at end of file diff --git a/Build/Font.GitHubWorkflow.PSDevOps.ps1 b/Build/Font.GitHubWorkflow.PSDevOps.ps1 new file mode 100644 index 0000000..f2c0265 --- /dev/null +++ b/Build/Font.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 Font Module" -On Push, + PullRequest, + Demand -Job TestPowerShellOnLinux, + TagReleaseAndPublish, BuildFont -Environment ([Ordered]@{ + REGISTRY = 'ghcr.io' + IMAGE_NAME = '${{ github.repository }}' + }) -OutputPath .\.github\workflows\BuildFont.yml + +Pop-Location \ No newline at end of file diff --git a/Build/Font.ezout.ps1 b/Build/Font.ezout.ps1 new file mode 100644 index 0000000..28f134b --- /dev/null +++ b/Build/Font.ezout.ps1 @@ -0,0 +1,39 @@ +#requires -Module EZOut +# Install-Module EZOut or https://github.com/StartAutomating/EZOut +$myFile = $MyInvocation.MyCommand.ScriptBlock.File +$myRoot = $myFile | Split-Path | Split-Path +$myModuleName = $myRoot | Split-Path -Leaf +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/Build/GitHub/Actions/FontAction.ps1 b/Build/GitHub/Actions/FontAction.ps1 new file mode 100644 index 0000000..2072bde --- /dev/null +++ b/Build/GitHub/Actions/FontAction.ps1 @@ -0,0 +1,351 @@ +<# +.Synopsis + GitHub Action for Font +.Description + GitHub Action for Font. This will: + + * Import Font + * If `-Run` is provided, run that script + * Otherwise, unless `-SkipScriptFile` is passed, run all *.Font.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 Font. +# 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 *.Font.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 = 'Font' +$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/BuildFont.psd1 b/Build/GitHub/Jobs/BuildFont.psd1 new file mode 100644 index 0000000..9755cbc --- /dev/null +++ b/Build/GitHub/Jobs/BuildFont.psd1 @@ -0,0 +1,18 @@ +@{ + "runs-on" = "ubuntu-latest" + if = '${{ success() }}' + steps = @( + @{ + name = 'Check out repository' + uses = 'actions/checkout@main' + }, + 'RunEZOut' # , + @{ + name = 'Run Action (on branch)' + if = '${{github.ref_name != ''main''}}' + uses = './' + id = 'Action' + } + # '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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..975128d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +## Font 0.1 + +* Initial Release of Font Module (#1) +* Core Commands: + * Get-Font (#2) + * Export-Font (#3) + * Import-Font (#4) +* Extended Types + * `Font.File` + * `get_FamilyName` (#5) + * `Font.svg` + * `get_FontFace` (#7) + * `get_FontStyle` (#8) + * `get/set_FamilyName` (#9) + * `get_FontWeight` (#10) + * `get_BoundingBox` (#11) + * `get_UnitsPerEm` (#12) + * `get_Descent` (#13) + * `get_Ascent` (#14) + * `GetGlyph()` (#15) + * `get_XML` (#17) + * `get/set_ID` (#28) + * `Font.Glyph` + * `get_SVG` (#18) + * `get_PathData` (#19) + * `get_Outline` (#20) + * `get_Motion` (#21) + * `.Save()` (#27) + * `.ToString()` (#30) +* Docs + * `README` (#22) + * `CONTRIBUTING.md` (#23) + * `CODE_OF_CONDUCT.md` (#24) + * `SECURITY.md` (#25) + * `FUNDING.yml` (#29) +* `Font` GitHub Action (#26) \ No newline at end of file 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..23e1e12 --- /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/Font/issues) or start a [discussion](https://github.com/PowerShellWeb/Font/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/Export-Font.ps1 b/Commands/Export-Font.ps1 new file mode 100644 index 0000000..c3ef77f --- /dev/null +++ b/Commands/Export-Font.ps1 @@ -0,0 +1,116 @@ +function Export-Font { + <# + .SYNOPSIS + Exports Fonts + .DESCRIPTION + Exports a font in any format supported by FontForge. + #> + param( + # The path to a font file. + [Parameter(Mandatory,ValueFromPipelineByPropertyName)] + [Alias('FullName')] + [string] + $FontPath, + + # The destination path. + # If this is not provided, all glyphs will be extracted to a local directory with the same name as the font. + [Alias('Destination')] + [string] + $DestinationPath + ) + + + begin { + # Find FontForge + $fontForgeCommand = $ExecutionContext.SessionState.InvokeCommand.GetCommand('fontforge','Application') + + # and prepare to write progress + $progressId = Get-Random + $outputQueue = [Collections.Queue]::new() + filter showProgress { + $output = $_ + Write-Progress -Id $progressId "$line " "$fontPath " + Write-Verbose $output + $outputQueue.Enqueue($output) + } + } + + process { + # If there's no font forge, there's not exporting or importing + if (-not $fontForgeCommand) { + Write-Error "FontForge is not installed or in the Path" + return + } + + $fontFile = Get-Item -Path $FontPath + + if (-not $PSBoundParameters.DestinationPath) { + $destinationPath = $PSBoundParameters.DestinationPath = "./$($($fontFile | Split-Path -Leaf))" + } + + # If we are outputting to a file, and it is not a .zip + if ($DestinationPath -like '*.*' -and + $DestinationPath -notlike '*.zip' + ) { + # we will try to trust that it is a file FontForge can generate + $fontForgeArgs = @( + '-lang=ff' + '-c' + @( + 'Open($1)' + 'SelectWorthOutputting()' + 'Generate($2)' + ) -join ';' + $FontPath + $DestinationPath + ) + & $fontForgeCommand @fontForgeArgs *>&1 | showProgress + + $exportedFontFile = Get-Item -Path $DestinationPath + if ($exportedFontFile.Extension -eq '.svg') { + $exportedFontFile.pstypenames.add('Font.svg') + } + $exportedFontFile.pstypenames.add('Font.File') + $exportedFontFile + } else { + $isZip = $DestinationPath -like '*.zip' + if ($isZip) { + $DestinationPath = $DestinationPath -replace '\.zip$' + } + $exists = Get-Item -Path $DestinationPath -ErrorAction Ignore + if (-not $exists) { + $exists = New-Item -ItemType Directory -Path $DestinationPath -Force + } + if ($exists -is [IO.FileInfo]) { + return + } + Push-Location $exists.FullName + $fontForgeArgs = @( + '-lang=ff' + '-c' + @( + 'Open($1)' + 'SelectWorthOutputting()' + 'foreach Export("svg")' + 'endloop' + ) -join ';' + + $FontPath + ) + + & $fontForgeCommand @fontForgeArgs *>&1 | showProgress + + if ($isZip) { + Compress-Archive -Path . -DestinationPath ($DestinationPath + '.zip') + Get-Item -LiteralPath ($DestinationPath + '.zip') + } else { + Get-Item -Path . + } + + Pop-Location + } + } + end { + Write-Progress -id $progressId -Completed + } +} diff --git a/Commands/Get-Font.ps1 b/Commands/Get-Font.ps1 new file mode 100644 index 0000000..aa1cfa2 --- /dev/null +++ b/Commands/Get-Font.ps1 @@ -0,0 +1,58 @@ +function Get-Font { + <# + .SYNOPSIS + Gets Fonts + .DESCRIPTION + Gets currently installed fonts + .EXAMPLE + Get-Font + #> + param() + + # Fonts will be in different places, depending on the operating system + $fontPaths = + if ($IsLinux) { + "/usr/share/fonts" + "~/.local/share/fonts" + } elseif ($IsMacOS) { + "/Library/Fonts" + "~/Library/Fonts" + } else { + "$env:WinDir\Fonts" + "$($env:AppData | + Split-Path | + Join-Path -ChildPath 'Local\Microsoft\Windows\Fonts')" + } + + # If we have fc-list, it will be quicker and more authoritative + $fcList = $ExecutionContext.SessionState.invokecommand.GetCommand('fc-list', 'Application') + + # Collect all of the font files + $fontFiles = + if ($fcList) { + & $fcList | + ForEach-Object { + $o = $_ + $file, $description = $o -split ':', 2 + $file -as [IO.FileInfo] + } | Sort-Object FullName + } else { + $fontPaths | + Get-ChildItem -Path { $_ } -ErrorAction Ignore -File -Recurse | + Where-Object Extension -in '.svg', '.ttf', '.otf', '.t1', '.pfb' + } + + + # Walk thru each list of collected files + foreach ($fontFile in $fontFiles) { + # and as long as they appear to be a font + if ($fontFile.Extension -notin '.svg', '.ttf', '.otf', '.t1', '.pfb') { + continue + } + # decorate them as a `Font.File` + $fontFile.pstypenames.add('Font.File') + # and output the font. + $fontFile + } +} + diff --git a/Commands/Import-Font.ps1 b/Commands/Import-Font.ps1 new file mode 100644 index 0000000..b02d789 --- /dev/null +++ b/Commands/Import-Font.ps1 @@ -0,0 +1,110 @@ +function Import-Font { + <# + .SYNOPSIS + Imports Fonts + .DESCRIPTION + Imports a Font as a SVG font. This allows us to easily modify the glyphs. + #> + param( + # The path to the font file. + [Parameter(Mandatory,ValueFromPipelineByPropertyName)] + [Alias('FullName')] + [string] + $FontPath + ) + + begin { + # Find font forge + $fontForgeCommand = $ExecutionContext.SessionState.InvokeCommand.GetCommand('fontforge','Application') + # and then find where we should put the local fonts + $myModuleName = $MyInvocation.MyCommand.ScriptBlock.Module.Name + if (-not $myModuleName) { + $myModuleName = @($MyInvocation.MyCommand.Name -split '-')[-1] + } + $myFontsDirectory = Join-Path ([Environment]::GetFolderPath("LocalApplicationData")) $myModuleName + + # Create a queue to store imports + $importQueue = [Collections.Queue]::new() + # and outputs + $outputQueue = [Collections.Queue]::new() + + # The fontForge script will always be the same + $fontForgeScript = @( + 'Open($1)' + 'SelectWorthOutputting()' + 'Generate($2)' + ) + + # And so will the first set of arguments to FontForge + $fontForgeArgs = @( + '-lang=ff' + '-c' + $fontForgeScript -join ';' + ) + filter showProgress { + $output = $_ + Write-Progress -Id $progressId "Importing Fonts $fontPath" "$line " -PercentComplete $percentComplete + Write-Verbose $output + $outputQueue.Enqueue($output) + } + } + + process { + # Add each font path to the queue + $importQueue.Enqueue($FontPath) + } + + end { + # Make sure font forge is installed + if (-not $fontForgeCommand) { + Write-Error "FontForge is not installed or in the Path" + return + } + + # and that the fonts directory exists + if (-not (Test-Path $myFontsDirectory)) { + $null = New-Item -ItemType Directory -Path $myFontsDirectory + if (-not $?) { return } + } + + # Write progress as we import fonts. + $progressId = Get-Random + $counter = 0 + $total = $importQueue.Count + while ($importQueue.Count) { + $fontPath = $importQueue.Dequeue() + $percentComplete = ( + $counter * 100 / $total + ) + $counter++ + + # Make sure we can get the font file + $fontFileInfo = Get-Item -Path $fontPath -ErrorAction Ignore + if (! $fontFileInfo) { continue } + + # Replace it's extension with svg + $destinationPath = $fontFileInfo.Name.Substring(0, $fontFileInfo.Name.Length - $fontFileInfo.Extension.Length) + '.svg' + # and determine the absolute destination path + $destinationPath = Join-Path $myFontsDirectory $destinationPath + + # Create an array with our paths + $pathArgs = @( + $FontPath + $DestinationPath + ) + + # Call font forge with our script and paths + & $fontForgeCommand @fontForgeArgs @pathArgs *>&1 | showProgress + # If that worked + if ($?) { + # get the file + $fontFile = Get-Item -Path $DestinationPath + # and decorate it as a `Font.svg` + $fontFile.pstypenames.add('Font.svg') + $fontFile + } + } + + Write-Progress "Importing fonts" "Complete!" -Completed -Id $progressId + } +} diff --git a/Examples/GitHubActionFonts.csv b/Examples/GitHubActionFonts.csv new file mode 100644 index 0000000..cb7a43d --- /dev/null +++ b/Examples/GitHubActionFonts.csv @@ -0,0 +1,60 @@ +"Name","FamilyName" +"Cantarell-Bold.otf","Cantarell" +"Cantarell-ExtraBold.otf","Cantarell" +"Cantarell-Light.otf","Cantarell" +"Cantarell-Regular.otf","Cantarell" +"Cantarell-Thin.otf","Cantarell" +"DejaVuMathTeXGyre.ttf","DejaVuMathTeXGyre" +"DejaVuSans-Bold.ttf","DejaVuSans" +"DejaVuSans-BoldOblique.ttf","DejaVuSans" +"DejaVuSans-ExtraLight.ttf","DejaVuSans" +"DejaVuSans-Oblique.ttf","DejaVuSans" +"DejaVuSans.ttf","DejaVuSans" +"DejaVuSansCondensed-Bold.ttf","DejaVuSansCondensed" +"DejaVuSansCondensed-BoldOblique.ttf","DejaVuSansCondensed" +"DejaVuSansCondensed-Oblique.ttf","DejaVuSansCondensed" +"DejaVuSansCondensed.ttf","DejaVuSansCondensed" +"DejaVuSansMono-Bold.ttf","DejaVuSansMono" +"DejaVuSansMono-BoldOblique.ttf","DejaVuSansMono" +"DejaVuSansMono-Oblique.ttf","DejaVuSansMono" +"DejaVuSansMono.ttf","DejaVuSansMono" +"DejaVuSerif-Bold.ttf","DejaVuSerif" +"DejaVuSerif-BoldItalic.ttf","DejaVuSerif" +"DejaVuSerif-Italic.ttf","DejaVuSerif" +"DejaVuSerif.ttf","DejaVuSerif" +"DejaVuSerifCondensed-Bold.ttf","DejaVuSerifCondensed" +"DejaVuSerifCondensed-BoldItalic.ttf","DejaVuSerifCondensed" +"DejaVuSerifCondensed-Italic.ttf","DejaVuSerifCondensed" +"DejaVuSerifCondensed.ttf","DejaVuSerifCondensed" +"Inconsolata.otf","Inconsolata" +"Lato-Black.ttf","Lato" +"Lato-BlackItalic.ttf","Lato" +"Lato-Bold.ttf","Lato" +"Lato-BoldItalic.ttf","Lato" +"Lato-Hairline.ttf","Lato" +"Lato-HairlineItalic.ttf","Lato" +"Lato-Heavy.ttf","Lato" +"Lato-HeavyItalic.ttf","Lato" +"Lato-Italic.ttf","Lato" +"Lato-Light.ttf","Lato" +"Lato-LightItalic.ttf","Lato" +"Lato-Medium.ttf","Lato" +"Lato-MediumItalic.ttf","Lato" +"Lato-Regular.ttf","Lato" +"Lato-Semibold.ttf","Lato" +"Lato-SemiboldItalic.ttf","Lato" +"Lato-Thin.ttf","Lato" +"Lato-ThinItalic.ttf","Lato" +"LiberationMono-Bold.ttf","LiberationMono" +"LiberationMono-BoldItalic.ttf","LiberationMono" +"LiberationMono-Italic.ttf","LiberationMono" +"LiberationMono-Regular.ttf","LiberationMono" +"LiberationSans-Bold.ttf","LiberationSans" +"LiberationSans-BoldItalic.ttf","LiberationSans" +"LiberationSans-Italic.ttf","LiberationSans" +"LiberationSans-Regular.ttf","LiberationSans" +"LiberationSerif-Bold.ttf","LiberationSerif" +"LiberationSerif-BoldItalic.ttf","LiberationSerif" +"LiberationSerif-Italic.ttf","LiberationSerif" +"LiberationSerif-Regular.ttf","LiberationSerif" +"NotoColorEmoji.ttf","NotoColorEmoji" diff --git a/Examples/Inconsolata_P.svg b/Examples/Inconsolata_P.svg new file mode 100644 index 0000000..5d7ffe7 --- /dev/null +++ b/Examples/Inconsolata_P.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/Examples/Inconsolata_q.svg b/Examples/Inconsolata_q.svg new file mode 100644 index 0000000..8a6deb1 --- /dev/null +++ b/Examples/Inconsolata_q.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/Examples/InstalledFonts.font.ps1 b/Examples/InstalledFonts.font.ps1 new file mode 100644 index 0000000..34cd6d0 --- /dev/null +++ b/Examples/InstalledFonts.font.ps1 @@ -0,0 +1,14 @@ +<# +.SYNOPSIS + Gets installed fonts +.DESCRIPTION + Gets the installed fonts +.NOTES + +#> + +Get-Font | + Select-Object Name, FamilyName | + Export-Csv ./GitHubActionFonts.csv + +Get-Item -Path ./GitHubActionFonts.csv \ No newline at end of file diff --git a/Examples/MindYourPsAndQs.font.ps1 b/Examples/MindYourPsAndQs.font.ps1 new file mode 100644 index 0000000..371453f --- /dev/null +++ b/Examples/MindYourPsAndQs.font.ps1 @@ -0,0 +1,25 @@ +<# +.SYNOPSIS + Mind your 'P's and 'q's +.DESCRIPTION + A simple demo to help you mind your 'P's and 'q's +.NOTES + The phrase "Mind your 'P's and 'q's" originates with typography. + + Uppercase P is typically the tallest letter, and lowercase q is typically the lowest letter. + + This little example imports the Inconsolata font and gets the 'P' and 'q' characters and saves them as an SVG. +#> +param( +[string] +$FontFamily = 'Inconsolata' +) + +$importedFont = + Get-Font | + Where-Object FamilyName -eq $FontFamily | + Select-Object -First 1 | + Import-Font + +$importedFont.GetGlyph("P").Save("./${FontFamily}_P.svg") +$importedFont.GetGlyph("q").Save("./${FontFamily}_q.svg") diff --git a/Font.psd1 b/Font.psd1 new file mode 100644 index 0000000..b48b725 --- /dev/null +++ b/Font.psd1 @@ -0,0 +1,170 @@ +# +# Module manifest for module 'Font' +# +# Generated by: James Brundage +# +# Generated on: 9/13/2025 +# + +@{ + +# Script module or binary module file associated with this manifest. +RootModule = 'Font.psm1' + +# Version number of this module. +ModuleVersion = '0.1' + +# Supported PSEditions +# CompatiblePSEditions = @() + +# ID used to uniquely identify this module +GUID = '5c2546c6-cc31-4d1f-9e1a-8613baebc8f6' + +# Author of this module +Author = 'James Brundage' + +# Company or vendor of this module +CompanyName = 'Start Automating' + +# Copyright statement for this module +Copyright = '(c) James Brundage. All rights reserved.' + +# Description of the functionality provided by this module +# Description = '' + +# Minimum version of the PowerShell engine required by this module +# PowerShellVersion = '' + +# Name of the PowerShell host required by this module +# PowerShellHostName = '' + +# Minimum version of the PowerShell host required by this module +# PowerShellHostVersion = '' + +# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# DotNetFrameworkVersion = '' + +# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. +# ClrVersion = '' + +# Processor architecture (None, X86, Amd64) required by this module +# ProcessorArchitecture = '' + +# Modules that must be imported into the global environment prior to importing this module +# RequiredModules = @() + +# Assemblies that must be loaded prior to importing this module +# RequiredAssemblies = @() + +# Script files (.ps1) that are run in the caller's environment prior to importing this module. +# ScriptsToProcess = @() + +# Type files (.ps1xml) to be loaded when importing this module +TypesToProcess = @('Font.types.ps1xml') + +# Format files (.ps1xml) to be loaded when importing this module +# FormatsToProcess = @() + +# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess +# NestedModules = @() + +# 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. +FunctionsToExport = @('Get-Font','Import-Font','Export-Font') + +# Cmdlets 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 cmdlets to export. +CmdletsToExport = @() + +# Variables to export from this module +VariablesToExport = '*' + +# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. +AliasesToExport = @('font') + +# DSC resources to export from this module +# DscResourcesToExport = @() + +# List of all modules packaged with this module +# ModuleList = @() + +# List of all files packaged with this module +# FileList = @() + +# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. +PrivateData = @{ + + PSData = @{ + + # Tags applied to this module. These help with module discovery in online galleries. + Tags = @('PowerShell', 'SVG', 'Font', 'FontForge') + + # A URL to the main website for this project. + ProjectUri = 'https://github.com/PowerShellWeb/Font' + + # A URL to the license for this module. + LicenseUri = 'https://github.com/PowerShellWeb/Font/blob/main/LICENSE' + + # A URL to an icon representing this module. + # IconUri = '' + + # ReleaseNotes of this module + # ReleaseNotes = '' + ReleaseNotes = @' +## Font 0.1 + +* Initial Release of Font Module (#1) +* Core Commands: + * Get-Font (#2) + * Export-Font (#3) + * Import-Font (#4) +* Extended Types + * `Font.File` + * `get_FamilyName` (#5) + * `Font.svg` + * `get_FontFace` (#7) + * `get_FontStyle` (#8) + * `get/set_FamilyName` (#9) + * `get_FontWeight` (#10) + * `get_BoundingBox` (#11) + * `get_UnitsPerEm` (#12) + * `get_Descent` (#13) + * `get_Ascent` (#14) + * `GetGlyph()` (#15) + * `get_XML` (#17) + * `get/set_ID` (#28) + * `Font.Glyph` + * `get_SVG` (#18) + * `get_PathData` (#19) + * `get_Outline` (#20) + * `get_Motion` (#21) + * `.Save()` (#27) + * `.ToString()` (#30) +* Docs + * `README` (#22) + * `CONTRIBUTING.md` (#23) + * `CODE_OF_CONDUCT.md` (#24) + * `SECURITY.md` (#25) + * `FUNDING.yml` (#29) +* `Font` GitHub Action (#26) +'@ + + # Prerelease string of this module + # Prerelease = '' + + # Flag to indicate whether the module requires explicit user acceptance for install/update/save + # RequireLicenseAcceptance = $false + + # External dependent modules of this module + # ExternalModuleDependencies = @() + + } # End of PSData hashtable + +} # End of PrivateData hashtable + +# 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/Font.psm1 b/Font.psm1 new file mode 100644 index 0000000..37d79bc --- /dev/null +++ b/Font.psm1 @@ -0,0 +1,52 @@ +$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 +$fontForge = $ExecutionContext.SessionState.InvokeCommand.GetCommand('fontforge','application') +if (-not $fontForge) { + if ($env:GITHUB_EVENT_PATH) { + "::group::Install Font Forge" | Out-Host + sudo apt-get install fontforge -y | Out-Host + if ($?) { + "FontForge Installed!" | Out-Host + } + "Fonts Found" | Out-Host + Get-Font | Out-Host + "::endgroup::" | Out-Host + $fontForge = $ExecutionContext.SessionState.InvokeCommand.GetCommand('fontforge','application') + } else { + Write-Warning "FontForge is not installed and in the path. Import/Export-Font will not work" + } +} +#endregion Custom + +Export-ModuleMember -Alias * -Function * -Variable $myModule.Name + + diff --git a/Font.tests.ps1 b/Font.tests.ps1 new file mode 100644 index 0000000..f8badf6 --- /dev/null +++ b/Font.tests.ps1 @@ -0,0 +1,12 @@ +describe Font { + it 'Gets fonts' { + $installedFonts = Get-Font + $installedFonts.Count | Should -BeGreaterThan 1 + } + + it 'Can import a font' { + $importedFont = Get-Font | Get-Random | Import-Font + $importedFont.FontFamily | Should -BeLike '*' + $importedFont.UnitsPerEm | Should -BeGreaterThan 0 + } +} diff --git a/Font.types.ps1xml b/Font.types.ps1xml new file mode 100644 index 0000000..707dca4 --- /dev/null +++ b/Font.types.ps1xml @@ -0,0 +1,229 @@ + + + + Font.File + + + Family + FamilyName + + + FontFamily + FamilyName + + + FamilyName + + $this.Name.Substring(0, $this.Name.Length - $this.Extension.Length) -replace '-.+?$' + + + + + + Font.Glyph + + + Save + + + + ToString + + + + Motion + + $fontFace = $this.ParentNode.'font-face' +$descent = $fontFace.descent -as [double] +$viewbox = $fontFace.bbox -split '\s' -as [double[]] +$PathData = $this.PathData +@( + "<svg xmlns='http://www.w3.org/2000/svg' viewBox='$viewbox' width='100%' height='100%' transform='scale(1 -1)'>" + "<path stroke='currentColor' fill='transparent' d='$($PathData)' />" +@" +<circle r="5" fill="currentColor"> + <animateMotion + dur="10s" + repeatCount="indefinite" + path="$($PathData)" /> + </circle> +"@ + "</svg>" +) -as [xml] + + + + + + Outline + + $fontFace = $this.ParentNode.'font-face' +$descent = $fontFace.descent -as [double] +$viewbox = $fontFace.bbox -split '\s' -as [double[]] +@("<svg xmlns='http://www.w3.org/2000/svg' viewBox='$viewbox' width='100%' height='100%' transform='scale(1 -1)'>" + +"<path stroke='currentColor' fill='transparent' d='$($this.PathData)' />" +"</svg>") -as [xml] + + + + PathData + + <# +.SYNOPSIS + Gets the glyph path data +.DESCRIPTION + Gets the glyph path data. + + Glyph paths are drawn upside down by default. +#> +$this.d + + + + SVG + + $fontFace = $this.ParentNode.'font-face' +$descent = $fontFace.descent -as [double] +$bbox = $fontFace.bbox -split '\s' -as [double[]] +$viewbox = $bbox +@( + "<svg xmlns='http://www.w3.org/2000/svg' viewBox='$viewbox' width='100%' height='100%' transform='scale(1 -1)'>" + "<path fill='currentColor' d='$($this.PathData)' />" + "</svg>" +) -as [xml] + + + + + + Font.svg + + + Face + FontFace + + + Family + FamilyName + + + FontFamily + FamilyName + + + GetGlyph + + + + Ascent + + $this.FontFace.'ascent' -as [float] + + + + BoundingBox + + $this.FontFace.bbox -split '\s' -as [float[]] + + + + Descent + + $this.FontFace.'descent' -as [float] + + + + FamilyName + + $this.FontFace.'font-family' + + + param([string]$FontFamily) +$this.FontFace.'font-family' = $FontFamily + + + + FontFace + + $this.XML | + Select-Xml -Namespace @{s='http://www.w3.org/2000/svg'} -XPath //s:font-face | + Select-Object -ExpandProperty Node + + + + FontStyle + + $this.FontFace.'font-style' + + + + FontWeight + + $this.FontFace.'font-weight' + + + + ID + + return $this.FontFace.ParentNode.id + + + param([string]$id) +$this.FontFace.ParentNode.id = $id + + + + UnitsPerEm + + $this.FontFace.'units-per-em' -as [float] + + + + XML + + if (-not $this.'#XML') { + if ($this -is [IO.FileInfo] -and $this.Extension -eq '.svg') { + $svgXml = (Get-Content -LiteralPath $this.FullName -Raw) -as [xml] + if ($svgXml) { + $this | Add-Member NoteProperty '#XML' $svgXml -Force + } + } +} +return $this.'#XML' + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 4dc0ba5..c8b8594 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,86 @@ # Font + Manage and Manipulate fonts with PowerShell + +With the Font module, you can: + +* `Get-Font` - Get Installed Fonts +* `Export-Font` - Export a font to various formats +* `Import-Font` - Import a font as SVG + +We can always get installed fonts. + +To Export and Import fonts, we need to install [FontForge](https://fontforge.org/), and ensure it is loaded in the path. + +## Fonts 101 + +Fonts are fun! + +Fonts are a series of symbols, often used to represent text. + +Each symbol in a font is called a `Glyph`. + +All of the glyphs in a font are called the `Font Face`. + +Fonts with similar faces (and often similar designers) are called a `Font Family`. + +The Font module is designed to help you work with fonts. + +It allows you to import and export fonts, get specific glyphs, and edit a typeface. + + +### Installing the Font Module + +~~~PowerShell +# Install the Font Module from the PowerShell Gallery +Install-Module Font +~~~ + +~~~PowerShell +# Import the font module +Import-Module Font +~~~ + +#### Cloning and Importing + +You can also clone the repository and import the module + +~~~PowerShell + +git clone https://github.com/PowerShellWeb/Font +cd ./Font +Import-Module ./ -Force -PassThru + +~~~ + +### Font GitHub Action + +The Font module has a GitHub action, and can be run in a workflow. + +To use the font action, simply refer to this repository: + +~~~yaml +- name: BuildFont + uses: PowerShellWeb/Font@main +~~~ + +Any file named `*.font.ps1` will be executed. + +Any outputted files will be checked in. + +## Basic Examples + +~~~PowerShell +# Import a random font +$importRandomFont = Get-Font | Get-Random | Import-Font + +# Get the bounding box +$importRandomFont.BoundingBox + +# Get the glyph for 'p' and get it's path data +$importRandomFont.GetGlyph("P").d +# We can change the path by setting 'd' +# Font glyphs are drawn "upside-down", and should fit within the bounding box. +~~~ + +For more examples, see the [Examples directory](./Examples/) \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..fd626ab --- /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/Font/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/Types/Font.File/Alias.psd1 b/Types/Font.File/Alias.psd1 new file mode 100644 index 0000000..81b36dc --- /dev/null +++ b/Types/Font.File/Alias.psd1 @@ -0,0 +1,4 @@ +@{ + Family = 'FamilyName' + FontFamily = 'FamilyName' +} \ No newline at end of file diff --git a/Types/Font.File/get_FamilyName.ps1 b/Types/Font.File/get_FamilyName.ps1 new file mode 100644 index 0000000..9e86801 --- /dev/null +++ b/Types/Font.File/get_FamilyName.ps1 @@ -0,0 +1 @@ +$this.Name.Substring(0, $this.Name.Length - $this.Extension.Length) -replace '-.+?$' \ No newline at end of file diff --git a/Types/Font.Glyph/Save.ps1 b/Types/Font.Glyph/Save.ps1 new file mode 100644 index 0000000..c48b4e8 --- /dev/null +++ b/Types/Font.Glyph/Save.ps1 @@ -0,0 +1,6 @@ +param([string]$FilePath) + +$unresolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($FilePath) +$createdFile = New-Item -ItemType File -Path $unresolvedPath +$this.SVG.Save($createdFile.FullName) +Get-Item -LiteralPath $createdFile.FullName \ No newline at end of file diff --git a/Types/Font.Glyph/ToString.ps1 b/Types/Font.Glyph/ToString.ps1 new file mode 100644 index 0000000..b958e06 --- /dev/null +++ b/Types/Font.Glyph/ToString.ps1 @@ -0,0 +1 @@ +"$($this.SVG.OuterXml)" \ No newline at end of file diff --git a/Types/Font.Glyph/get_Motion.ps1 b/Types/Font.Glyph/get_Motion.ps1 new file mode 100644 index 0000000..01df890 --- /dev/null +++ b/Types/Font.Glyph/get_Motion.ps1 @@ -0,0 +1,18 @@ +$fontFace = $this.ParentNode.'font-face' +$descent = $fontFace.descent -as [double] +$viewbox = $fontFace.bbox -split '\s' -as [double[]] +$PathData = $this.PathData +@( + "" + "" +@" + + + +"@ + "" +) -as [xml] + diff --git a/Types/Font.Glyph/get_Outline.ps1 b/Types/Font.Glyph/get_Outline.ps1 new file mode 100644 index 0000000..b878098 --- /dev/null +++ b/Types/Font.Glyph/get_Outline.ps1 @@ -0,0 +1,7 @@ +$fontFace = $this.ParentNode.'font-face' +$descent = $fontFace.descent -as [double] +$viewbox = $fontFace.bbox -split '\s' -as [double[]] +@("" + +"" +"") -as [xml] \ No newline at end of file diff --git a/Types/Font.Glyph/get_PathData.ps1 b/Types/Font.Glyph/get_PathData.ps1 new file mode 100644 index 0000000..0b51f9f --- /dev/null +++ b/Types/Font.Glyph/get_PathData.ps1 @@ -0,0 +1,9 @@ +<# +.SYNOPSIS + Gets the glyph path data +.DESCRIPTION + Gets the glyph path data. + + Glyph paths are drawn upside down by default. +#> +$this.d \ No newline at end of file diff --git a/Types/Font.Glyph/get_SVG.ps1 b/Types/Font.Glyph/get_SVG.ps1 new file mode 100644 index 0000000..be41826 --- /dev/null +++ b/Types/Font.Glyph/get_SVG.ps1 @@ -0,0 +1,9 @@ +$fontFace = $this.ParentNode.'font-face' +$descent = $fontFace.descent -as [double] +$bbox = $fontFace.bbox -split '\s' -as [double[]] +$viewbox = $bbox +@( + "" + "" + "" +) -as [xml] \ No newline at end of file diff --git a/Types/Font.svg/Alias.psd1 b/Types/Font.svg/Alias.psd1 new file mode 100644 index 0000000..e55a899 --- /dev/null +++ b/Types/Font.svg/Alias.psd1 @@ -0,0 +1,5 @@ +@{ + Family = 'FamilyName' + FontFamily = 'FamilyName' + Face = 'FontFace' +} \ No newline at end of file diff --git a/Types/Font.svg/GetGlyph.ps1 b/Types/Font.svg/GetGlyph.ps1 new file mode 100644 index 0000000..15fd4a7 --- /dev/null +++ b/Types/Font.svg/GetGlyph.ps1 @@ -0,0 +1,20 @@ +param( +[PSObject]$value +) + +filter toGlyph { + $_.Node.pstypenames.add('Font.glyph') + $_.Node +} +if ($value -is [string] -and $value.Length -le 2) { + $escapedValue = $($value -replace "'","''") + $this.XML | + Select-Xml -Namespace @{s='http://www.w3.org/2000/svg'} -XPath "//s:glyph[@unicode='$escapedValue'] | //s:glyph[@glyph-name='$escapedValue']" | + toGlyph +} + +if ($null -eq $value) { + $this.XML | + Select-Xml -Namespace @{s='http://www.w3.org/2000/svg'} -XPath "//s:glyph" | + toGlyph +} diff --git a/Types/Font.svg/get_Ascent.ps1 b/Types/Font.svg/get_Ascent.ps1 new file mode 100644 index 0000000..a3a78a5 --- /dev/null +++ b/Types/Font.svg/get_Ascent.ps1 @@ -0,0 +1 @@ +$this.FontFace.'ascent' -as [float] \ No newline at end of file diff --git a/Types/Font.svg/get_BoundingBox.ps1 b/Types/Font.svg/get_BoundingBox.ps1 new file mode 100644 index 0000000..6f49e82 --- /dev/null +++ b/Types/Font.svg/get_BoundingBox.ps1 @@ -0,0 +1 @@ +$this.FontFace.bbox -split '\s' -as [float[]] \ No newline at end of file diff --git a/Types/Font.svg/get_Descent.ps1 b/Types/Font.svg/get_Descent.ps1 new file mode 100644 index 0000000..3cb31ef --- /dev/null +++ b/Types/Font.svg/get_Descent.ps1 @@ -0,0 +1 @@ +$this.FontFace.'descent' -as [float] \ No newline at end of file diff --git a/Types/Font.svg/get_FamilyName.ps1 b/Types/Font.svg/get_FamilyName.ps1 new file mode 100644 index 0000000..5a46d9d --- /dev/null +++ b/Types/Font.svg/get_FamilyName.ps1 @@ -0,0 +1 @@ +$this.FontFace.'font-family' \ No newline at end of file diff --git a/Types/Font.svg/get_FontFace.ps1 b/Types/Font.svg/get_FontFace.ps1 new file mode 100644 index 0000000..b082d3d --- /dev/null +++ b/Types/Font.svg/get_FontFace.ps1 @@ -0,0 +1,3 @@ +$this.XML | + Select-Xml -Namespace @{s='http://www.w3.org/2000/svg'} -XPath //s:font-face | + Select-Object -ExpandProperty Node \ No newline at end of file diff --git a/Types/Font.svg/get_FontStyle.ps1 b/Types/Font.svg/get_FontStyle.ps1 new file mode 100644 index 0000000..972820f --- /dev/null +++ b/Types/Font.svg/get_FontStyle.ps1 @@ -0,0 +1 @@ +$this.FontFace.'font-style' \ No newline at end of file diff --git a/Types/Font.svg/get_FontWeight.ps1 b/Types/Font.svg/get_FontWeight.ps1 new file mode 100644 index 0000000..71d83e3 --- /dev/null +++ b/Types/Font.svg/get_FontWeight.ps1 @@ -0,0 +1 @@ +$this.FontFace.'font-weight' \ No newline at end of file diff --git a/Types/Font.svg/get_ID.ps1 b/Types/Font.svg/get_ID.ps1 new file mode 100644 index 0000000..53cdbc9 --- /dev/null +++ b/Types/Font.svg/get_ID.ps1 @@ -0,0 +1 @@ +return $this.FontFace.ParentNode.id \ No newline at end of file diff --git a/Types/Font.svg/get_UnitsPerEm.ps1 b/Types/Font.svg/get_UnitsPerEm.ps1 new file mode 100644 index 0000000..322a044 --- /dev/null +++ b/Types/Font.svg/get_UnitsPerEm.ps1 @@ -0,0 +1 @@ +$this.FontFace.'units-per-em' -as [float] \ No newline at end of file diff --git a/Types/Font.svg/get_XML.ps1 b/Types/Font.svg/get_XML.ps1 new file mode 100644 index 0000000..aa24e32 --- /dev/null +++ b/Types/Font.svg/get_XML.ps1 @@ -0,0 +1,9 @@ +if (-not $this.'#XML') { + if ($this -is [IO.FileInfo] -and $this.Extension -eq '.svg') { + $svgXml = (Get-Content -LiteralPath $this.FullName -Raw) -as [xml] + if ($svgXml) { + $this | Add-Member NoteProperty '#XML' $svgXml -Force + } + } +} +return $this.'#XML' \ No newline at end of file diff --git a/Types/Font.svg/set_FamilyName.ps1 b/Types/Font.svg/set_FamilyName.ps1 new file mode 100644 index 0000000..0204ea0 --- /dev/null +++ b/Types/Font.svg/set_FamilyName.ps1 @@ -0,0 +1,2 @@ +param([string]$FontFamily) +$this.FontFace.'font-family' = $FontFamily \ No newline at end of file diff --git a/Types/Font.svg/set_ID.ps1 b/Types/Font.svg/set_ID.ps1 new file mode 100644 index 0000000..fc31893 --- /dev/null +++ b/Types/Font.svg/set_ID.ps1 @@ -0,0 +1,2 @@ +param([string]$id) +$this.FontFace.ParentNode.id = $id \ No newline at end of file diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..420f9f6 --- /dev/null +++ b/action.yml @@ -0,0 +1,444 @@ + +name: BuildFont +description: Turtles in a PowerShell +inputs: + Run: + required: false + description: | + A PowerShell Script that uses Font. + 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 *.Font.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: type + color: blue +runs: + using: composite + steps: + - name: FontAction + id: FontAction + shell: pwsh + env: + NoCommit: ${{inputs.NoCommit}} + SkipScriptFile: ${{inputs.SkipScriptFile}} + UserEmail: ${{inputs.UserEmail}} + InstallModule: ${{inputs.InstallModule}} + NoPush: ${{inputs.NoPush}} + GitHubToken: ${{inputs.GitHubToken}} + ActionScript: ${{inputs.ActionScript}} + CommitMessage: ${{inputs.CommitMessage}} + UserName: ${{inputs.UserName}} + Run: ${{inputs.Run}} + TargetBranch: ${{inputs.TargetBranch}} + 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:: FontAction $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')" + & {<# + .Synopsis + GitHub Action for Font + .Description + GitHub Action for Font. This will: + + * Import Font + * If `-Run` is provided, run that script + * Otherwise, unless `-SkipScriptFile` is passed, run all *.Font.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 Font. + # 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 *.Font.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 = 'Font' + $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 +