---
title: "Poaster: Solving SSG Microblogging Ergonomics with Ruby and KDialog"
description: "Trying to make it a little less brutal to make small, frequent posts on SSGs."
date: 2025-06-08
tags:
 - Site Updates
 - Eleventy
 - KDE
synopsis: "Trying to make it a little less brutal to make small, frequent posts on SSGs."
imageURL: "/img/poaster/poaster_icon.svg"
imageAlt: "An icon featuring a red toaster with the Ruby diamond on it popping out a sheet with the Markdown logo on it as though it were toast."
mastodon_id: "114650833104413858"
---

Anyone familiar with my blog will know that I like to write about incense. A reader wrote to me some time ago asking about what sticks I've been enjoying lately, and it occurred to me that it might be a nice thing to have a "now listening" type feature on my website, so that fellow incense heads could get a sense of the types of incense I like. After all, while I write plenty of incense reviews, they represent only a small percentage of the sticks, cones, powders, woods, and resins I'm burning or heating from day to day. (If you're here for my incense content, feel free to skip this one and head to [/now-burning](/now-burning) to see the new feature!)

## The issue of ergonomics

While it would have been simple enough for me to build a microblogging feature into my [Eleventy](https://www.11ty.dev/) website, the trouble was *wanting to use it* after it was built. Unlike using a {{ "CMS" | abbr("Content Management System") | safe }} such as WordPress to make a website, I knew of no nice interface for Eleventy, or for that matter any {{ "SSG" | abbr("Static Site Generator") | safe }}, that would help me create a post and publish it online without opening an {{ "IDE" | abbr("Integrated Development Environment") | safe }}[^1] and using the command line. Instead, the process looks something like this:

[![A screenshot of a complicated looking text editor with a terminal widget at the bottom beside a copy of my website running on localhost.](/img/poaster/ergonomics_fail.webp "I don't necessarily want to feel like a [hackerman](https://knowyourmeme.com/memes/hackerman) every time I decide to make a tiny status update. Also, I just noticed that I totally screwed up the frontmatter for that post.")](/img/poaster/ergonomics_fail.webp)

As big of a nerd as I am, I'm just not going to want to do that multiple times a day for what amounts to a status post. This lead me to *scour* the internet looking for a solution: something that I could run on my own desktop or laptop that could build my site locally and push changes to my website, hosted the old fashioned way: as a bunch of text files sitting on a server accessible via {{ "SFTP" | abbr("SSH File Transfer Protocol") | safe }}. No needless complexity like running Eleventy on the server, or using a host like Netlify.[^2] Surely there'd be something, right? Surely, the realm of SSGs can't be without at least one nice, local user interface that people can use without being a web developer?

## An attempt to fix the problem

In the end, I did find one answer to the problem:  [Publii](https://getpublii.com/). Publii seems to be made predominantly with end-users in mind, however. It's not just a local[^3] CMS, it's an SSG in its own right, which does me no good as I can't make it work with my website[^4]. So after coming up with nothing *I* could use, I gave the idea a rest for a while until I had the epiphany that I could solve the problem with a simple script using KDE's [KDialog](https://invent.kde.org/utilities/kdialog) to provide a rudimentary UI. So that's what I did.

The idea was simple: a [wizard](https://en.wikipedia.org/wiki/Wizard_(software))-like experience that guides the user through the creation of a microblog / status post. Post types and the data they collect should be customized by the user via a JSON configuration file. After the post data is collected from the user, the script should execute a user-defined build command as well as a user-defined command to sync the static files to the server.
## Building "Poaster"

For some reason, I decided to write my script in Ruby, a language for which I once completed a course before promptly forgetting everything I knew about it. I would have had a much easier time using JavaScript and Node, which I am much more familiar with and have successfully used for similar purposes. Why I did not is anyone's guess. All this to say: please do not make (too much) fun of my shitty little script, which I have dubbed "Poaster."

I started with the JSON configuration file, `/Poaster/config/config.json`:

```json
{
    "buildCommand": "npx @11ty/eleventy",
    "postTypes": [
        {
            "name": "Now Burning",
            "postUnitName": "incense",
            "contentEnabled": true,
            "frontMatter": [
                {
                    "name": "title"
                },
                {
                    "name": "manufacturer"
                },
                {
                    "name": "date"
                },
                {
                    "name": "time"
                }
            ],
            "postDirectory": "/post/output/dir"
        }
    ],
    "uploadCommand": "rsync -av --del /local/path/to/site/output
username@my.server:/remote/path/to/public/site/files",
    "siteDirectory": "/local/path/to/site/repo"
}
```
Here, the user can specify as many post types as they like, each with their own output directory. Each post type can also collect as many pieces of frontmatter as the user cares to specify.

The first thing the script needed to do was ask the user which post type they want to create, so I referenced the [KDialog tutorial](https://develop.kde.org/docs/administration/kdialog/) and wrote a method to handle that `/Poaster/lib/spawn_radio_list.rb`:
``` ruby
def spawn_radio_list(title, text, options_arr)
  command = %(kdialog --title "#{title}" --radiolist "#{text}")
  options_arr.each_with_index do |option, i|
    command += %( #{i} "#{option}" off)
  end
  `#{command}`
end
```
I wrote a few more methods in `/Poaster/lib` to spawn toast notifications, input boxes, create directories if they don't exist, and write files:
`/Poaster/lib/spawn_toast.rb`:
``` ruby
def spawn_toast(title, text, seconds)
  `kdialog --title "#{title}" --passivepopup "#{text}" #{seconds}`
end
```
`/Poaster/lib/spawn_input_box.rb`:
``` ruby
def spawn_input_box(title, text)
  `kdialog --title "#{title}" --inputbox "#{text}"`
end
```
`/Poaster/lib/ensure_dir_exists.rb`:
``` ruby
def ensure_dir_exists(directory_path)
  unless Dir.exist?(directory_path)
    FileUtils.mkdir_p(directory_path)
    spawn_toast 'Directory Created', %(Poaster created #{directory_path}.), 10
  end
end
```
`/Poaster/lib/write_file.rb`:
``` ruby
def write_file(directory, name, extension, content)
  post_file = File.new(%(#{directory}/#{name}.#{extension}), 'w+')
  post_file.syswrite(content)
  post_file.close
end
```
All I had to do then was tie it all together in `/Poaster/poaster.rb`:
``` ruby
#!/usr/bin/env ruby
require 'json'
require 'fileutils'
require './lib/spawn_input_box'
require './lib/spawn_radio_list'
require './lib/spawn_toast'
require './lib/ensure_dir_exists'
require './lib/write_file'

config_data = JSON.parse(File.read('./config/config.json'))
dialog_title_prefix = 'Poaster'

# Populate types_arr with post types
post_types_arr = []
config_data['postTypes'].each do |type|
  post_types_arr.push(type['name'])
end

# Display post list dialog to user
post_type = config_data['postTypes'][Integer(spawn_radio_list(dialog_title_prefix, 'Select a post type:', post_types_arr))]

# Set the word we will use to refer to the post
post_unit = post_type['postUnitName']

# Collect frontmatter from user
frontmatter = []
post_type['frontMatter'].each do |item|
  frontmatter.push({ item['name'] => spawn_input_box(%(#{dialog_title_prefix} - Enter Frontmatter'), %(Enter #{post_unit} #{item['name']}:)) })
end

# Collect post content from user
post_content = spawn_input_box %(#{dialog_title_prefix} - Enter Content), %(Enter #{post_unit} content:)

# Make sure the output folder exists
post_directory = post_type['postDirectory']
ensure_dir_exists(post_directory)

# Create post string
post = %(---\n)
post_id = ''
frontmatter.each_with_index do |item, i|
  post += %(#{item.keys[0]}: #{item[item.keys[0]]})
  post_id += %(#{item[item.keys[0]].chomp}#{i == frontmatter.length - 1 ? '' : '_'})
end
post += %(---\n#{post_content})

# Write post string to file and notify user
post_file_name = %(#{post_type['name']}_#{post_id.chomp})
post_extension = 'md'

write_file post_directory, post_file_name, post_extension, post
spawn_toast 'File Created', %(Poaster created #{post_file_name}#{post_extension} at #{post_directory}.), 10

# Run build and upload commands
`cd #{config_data['siteDirectory']} && #{config_data['buildCommand']} && #{config_data['uploadCommand']}`
```

## Burning now
There is a lot that this script should do that it doesn't, but for now, it's still a handy wee utility for SSG users on GNU/Linux systems running KDE who want to make creating quick status-type posts a little less painful. Just make sure KDialog is installed (as well as Ruby, naturally), clone [the repo](https://upchur.ch/gitea/n_u/Poaster), create `/Poaster/config/config.json` to meet your needs using the example as a reference and you're off to the races! I've even made a silly little toaster icon using assets from some of the KDE MimeType icons that you can use if you want to make a `.desktop` file so that you can click an icon on your app launcher to start the script.

[![A screenshot showing Poaster in my app launcher.](/img/poaster/app-menu.webp "Isn't that nice?")](/img/poaster/app-menu.webp)

My `poaster.desktop` file looks something like this:
``` bash
[Desktop Entry]
Exec=/path/to/poaster.rb
GenericName[en_US]=Create a post with Poaster.
GenericName=Create a post with Poaster.
Icon=/path/to/poaster_icon.svg
Name=Poaster
NoDisplay=false
Path=/path/to/repo/
StartupNotify=true
Terminal=false
Type=Application
```
Here's the script in action:
<figure><div style="position: relative;"><iframe title="A video showing Poaster being launched from the terminal. The script brings up a series of text input boxes for each piece of frontmatter specified in the configuration file." width="560" height="315" src="https://makertube.net/videos/embed/p8oopZXaLNUXNpBQGY9q4k" frameborder="0" allowfullscreen="" sandbox="allow-same-origin allow-scripts allow-popups allow-forms"></iframe></div><figcaption>The ease! The convenience!</figcaption></figure>

To build the new "now burning" incense microblog feature, I created two new pages. [/now-burning](/now-burning) shows the latest entry:

``` html
---
layout: layouts/base.njk
title: "Nathan Upchurch | Now Burning: What incense I'm burning at the moment."
structuredData: none
postlistHeaderText: "What I've been burning:"
---
{% raw %}{% set burning = collections.nowBurning | last %}

<h1>Now Burning:</h1>
<article class="post microblog-post">
	<img class="microblog-icon" src="/img/censer.svg">
	<div class="microblog-status">
		<h2 class="">{{ burning.data.title }}{% if burning.data.manufacturer %}, {{ burning.data.manufacturer }}{% endif %}, {{ burning.date | niceDate }}, {{ burning.data.time }}</h2>
		{% if burning.content %}
		<div class="microblog-comment">
			{{ burning.content | safe }}
		</div>
		{% endif %}{% endraw %}
	</div>
</article>
<a href="/once-burned/">
	<button type="button">Previous Entries »</button>
</a>
```
…and [/once-burned](/once-burned) shows past entries:
``` html
---
layout: layouts/base.njk
title: "Nathan Upchurch | Once Burned: Incense I've burning in the past."
structuredData: none
---
{% raw %}{% set burning = collections.nowBurning | last %}

<h1>Previous “Now Burning” Entries:</h1>
{% set postsCount = collections.nowBurning | removeMostRecent | length %}
{% if postsCount > 0 %}
{% set postslist = collections.nowBurning | removeMostRecent %}
{% set showPostListHeader = false %}
{% include "incenseList.njk" %}
{% else %}
<p>Nothing’s here yet!</p>
{% endif %}{% endraw %}
<a href="/now-burning/">
	<button type="button">Latest »</button>
</a>
```
…using a post-listing include built specifically for microblogging:

``` html
<section class="postlist microblog-list">
	{% raw %}{% if postlistHeaderText %}<h2>{{ postlistHeaderText }}</h2>{% endif %}
	<div class="postlist-item-container">
		{% for post in postslist | reverse %}
		<article class="postlist-item">

			<div class="post-copy">
					<h3>
						{% if post.data.title %}{{ post.data.title | safe }}{% else %}?{% endif %}{% if post.data.manufacturer %}, {{ post.data.manufacturer | safe }}{% endif %}
					</h3>

				<div class="post-metadata">
					<div class="post-metadata-copy">
						<p>
						<time datetime="{{ post.date | htmlDateString }}">{{ post.date | niceDate }}{% if post.data.time %}—{{ post.data.time }}{% endif %}</time>
						</p>
					</div>
				</div>
				{% if post.content %}
				<div class="microblog-comment">
					{{ post.content | safe }}
				</div>
				{% endif %}
			</div>
		</article>
		<hr>
		{% endfor %}{% endraw %}
	</div>
</section>
```
And that's about it! There's a lot to do to make the script a little less fragile, such as passing along build / upload error messages, allowing for data validation via regex, et cetera. I'm sure I'll get to it at some point. If Poaster is useful to you, however, and you'd like to submit a patch to improve it, [please do let me know](../../me/).

[^1]: Yes, I am aware that [Kate](https://kate-editor.org/) isn't *technically*
an IDE.

[^2]: At risk of sounding crabbit and behind the times, I don't know why web
development has to be so damned complicated these days. Like, an entire fancy
for-profit infrastructural platform that exists just to host static websites?
It seems nuts to me.

[^3]: Thank christ. Why does everything need to run in the cloud when we
already have computers at home?

[^4]: I did however use it to very quickly set up a nice looking blog site for
my partner.

