I recently migrated this site off of a unnamed hosted blogging platform. I’d hoped it’d make it easier to blog more, but in practice:

  1. Support was non-existent! They charged $10/mo for a newsletter feature, but I waited a few weeks without any reply to an email about it their renderer messing up formatting.
  2. Their editor was really, really bad, despite being based on CodeMirror, which is normally OK.

So back to Zola it is! One thing I missed was the ability to quickly create a new blog post. That service had apps which meant if I wanted to post something short, all I had to do was tap “New” then type in some text. In contrast, with the static site generator, I have to create front matter, choose a title and filename, enter the date, etc…

Because I was curious about the landscape of Bash replacements, I decided to try write it in YSH. Unfortunately I ran into some showstopper bugs—pipelines of depth more than one would fail:

ysh ysh-0.19.0$ echo | cat | cat
oils I/O error (main): No such process

… so I decided to try PowerShell! Unfortunately it was about as much of an unpleasant experience as I expected. But what follows is the script I use to create new blog posts in all its glory. I placed it in a file called n in my blogging directory, so ./n launches Neovim with a new blog post with front matter populated with the current date. Exiting Neovim saves it to content/blog/slug-here.md, where the slug either comes from the title in the front matter or the first few words of the content (for microblogging).

This is the only PowerShell I have ever written and hopefully ever will write, so don’t take anything I wrote as best practice:

$ErrorActionPreference = "Stop"

# Parse a Zola-style blog post with front-matter to extract its body (without
# the front matter) and compute a filename and slug based off of its title or
# content.
function Parse-Blog-Post ($Content) {
  $state = "start"
  $title = ""
  $body = ""

  foreach ($line in $Content -split "\n") {
    switch ($state) {
      "start" {
        if ($line -eq "+++") {
          $state = "frontmatter"
        }
      }
      "frontmatter" {
        if ($line -eq "+++") {
          $state = "body"
        } elseif ($line -match "^title =") {
          $title = $line -replace '^title =', '' | ConvertFrom-Json
          break
        }
      }
      "body" {
        $body += "$line`n"
      }
    }
  }

  # If a title was specified, use that.
  # Otherwise, take the first few words of the body and slugify them.
  $slug = if ($title) {
    $title.ToLower() -replace '[^a-zA-Z0-9-]', '-' `
      -replace '-+', '-' `
      -replace '(^-|-$)', ''
  } else {
    $cleaned = $body.ToLower() `
      -replace '<[^>]+>', '' `
      -replace '&[a-z]+;', '' `
      -replace '[^a-zA-Z0-9 ]', '' `
      -replace ' +', '-' `
      -replace '-+', '-' `
      -replace '(^-|-$)', ''
    if ($cleaned -like "*-*") {
      $parts = $cleaned.Split('-')
      $filtered = ($parts | where {$_ -notmatch "^(is|am|are|was|were|being|been|have|has|had|do|done|did|might|must|can|could|shall|should|will|would|currently|the|of|by|id|a|in|was|and|i|or)$"})
      $filtered[0..4] -join '-'
    } else {
      $cleaned
    }
  }

  # Use $slug-$n as the filename if $slug, $slug-1, ..., $slug-(n-1) are taken.
  $file = "content/blog/$slug.md"
  $count = 1

  while (Test-Path $file) {
    $count++
    $file = "content/blog/$slug-$count.md"
  }

  if ($count -gt 1) {
    $slug = "$slug-$count"
  }

  return @{
    file = $file;
    slug = $slug;
    body = $body;
  }
}

$tmp = New-TemporaryFile
$date = Get-Date -Format "o"

Set-Content -Path $tmp.FullName -Value @"
+++
date = $date
draft = false
+++

Hello, world!
"@

$editor = $Env:EDITOR
Start-Process -FilePath "$editor" -ArgumentList $tmp.FullName -Wait

$parsed = Parse-Blog-Post (Get-Content -Path $tmp.FullName)

if (!$parsed.body) {
    echo "Empty body; exiting."
    Exit
}

Move-Item -Path $tmp.FullName -Destination $parsed.file
git add $parsed.file

Notes from the perspective of a Bash/Zsh user, starting with the bad:

  1. Nightmare: I tried to make Get-Content ... | Parse-Blog-Post work, but it turns out that that’s a nightmare. A Microsoft blog post purports to explain, but I never got it to work… The syntax is the stuff of nightmares; Function Parse-Blog-Post ($Content) { becomes something like this excerpt from that blog post:
Function Get-Something {
  [CmdletBinding()]
  Param(
    [Parameter(ValueFromPipelineByPropertyName)]
    $Name
  )
  process {
    Write-Host "You passed the parameter $Name into the function"
  }
}
  1. Confusing: While testing, I like to put exit in my Bash scripts so I can exit early before stuff with side-effects runs. In PowerShell, this can actually shut off streams before they are flushed to stdout, so it’s really not recommended. For example, this outputs nothing when placed in a file and passed as an argument to the pwsh command if I remove the | Out-Host, but works fine when run in interactive mode:
$feed = Invoke-RestMethod -Uri "https://example.com/feed.json"
foreach ($item in $feed.items) {
  echo $item | Out-Host
}
exit 0
  1. Confusing: Not sure why the language designers decided to use infix operators like -split for some operations and methods like ToLower() for others.
  2. Weird: The syntax for multi-line statements is kind of wonky. I got the chained -replaces to work, but I tried chaining method calls and got weird errors.
  3. Lame: For a Bash/Zsh user, nothing will have the names or options you expect. Microsoft made a half-hearted attempt to alias some functions, e.g. rm is aliased to Remove-Item, but even rm -rf doesn’t work. Instead it’s rm <path> -r -fo, which is such a joy to type out.
  4. Cute: $parts | where {$_ -notmatch "some-regex"} is cute.
  5. Cool: x | ConvertFrom-Json is nice. PowerShell even has a built-in, Invoke-RestMethod -Uri "https://example.com/feed.json", that fetches JSON at some URL and parses it.
  6. Nice: Having native datatypes like dictionaries, arrays, and other .NET types is nifty. You can even parse datetimes with casting magic:
date = [DateTime]$datetime_string

Overall, would I recommend PowerShell for scripting outside of Windows? ConvertFrom-Json and Invoke-RestMethod -Uri are cool. And if I got used to it I’m sure I could use it to quickly whip up scripts.

The problem is that as soon as my script is mostly logic instead of piping programs that write to/read from stdout together, I could also just write something in a real programming language, like one of the billion competing JavaScript/TypeScript runtimes (Node, Deno, Bun…), or Python, or Ruby, or Elixir.

Bash is great at piping programs together, but the rest of the language is extremely unpleasant, especially when you start dealing with things like JSON.

PowerShell is also pretty good at piping programs together, but the rest of the language is… slightly more powerful, but also kind of quirky, for no clear reason! What other programming language has syntax like foreach ($line in $Content -split "\n")? It’s also arguably worse at piping programs together than Bash; if I write a new Bash function, I can easily pipe things to it and have it write e.g. output line-by-line. I’m not sure what’s going on with CmdletBinding and ValueFromPipelineByPropertyName in PowerShell.

Lastly, PowerShell is really slow to start: around 20 times slower than Bash. Here’s PowerShell vs. Bash:

$ for i in $(seq 1 10); do time pwsh -i -c exit; done;
pwsh -i -c exit  0.20s user 0.12s system 127% cpu 0.254 total
pwsh -i -c exit  0.18s user 0.11s system 133% cpu 0.213 total
pwsh -i -c exit  0.19s user 0.11s system 133% cpu 0.222 total
pwsh -i -c exit  0.19s user 0.11s system 131% cpu 0.226 total
pwsh -i -c exit  0.19s user 0.11s system 130% cpu 0.227 total
pwsh -i -c exit  0.18s user 0.10s system 127% cpu 0.222 total
pwsh -i -c exit  0.18s user 0.10s system 131% cpu 0.207 total
pwsh -i -c exit  0.18s user 0.10s system 131% cpu 0.212 total
pwsh -i -c exit  0.18s user 0.09s system 125% cpu 0.213 total
pwsh -i -c exit  0.18s user 0.11s system 131% cpu 0.215 total

$ for i in $(seq 1 10); do time bash -i -c exit; done;
bash -i -c exit  0.01s user 0.01s system 76% cpu 0.026 total
bash -i -c exit  0.01s user 0.01s system 79% cpu 0.016 total
bash -i -c exit  0.01s user 0.00s system 84% cpu 0.011 total
bash -i -c exit  0.00s user 0.00s system 86% cpu 0.011 total
bash -i -c exit  0.00s user 0.00s system 84% cpu 0.011 total
bash -i -c exit  0.00s user 0.00s system 82% cpu 0.011 total
bash -i -c exit  0.00s user 0.00s system 82% cpu 0.011 total
bash -i -c exit  0.00s user 0.00s system 85% cpu 0.010 total
bash -i -c exit  0.00s user 0.00s system 78% cpu 0.012 total
bash -i -c exit  0.00s user 0.00s system 75% cpu 0.011 total