Skip to content

Poaster: Solving SSG Microblogging Ergonomics with Ruby and KDialog

Sunday, 8 June 2025  |  Nathan Upchurch

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 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 website, the trouble was wanting to use it after it was built. Unlike using a CMS such as WordPress to make a website, I knew of no nice interface for Eleventy, or for that matter any SSG, that would help me create a post and publish it online without opening an IDE[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.
I don’t necessarily want to feel like a 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.

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. 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. 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 to provide a rudimentary UI. So that’s what I did.

The idea was simple: a wizard-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:

{
    "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 and wrote a method to handle that /Poaster/lib/spawn_radio_list.rb:

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:

def spawn_toast(title, text, seconds)
  `kdialog --title "#{title}" --passivepopup "#{text}" #{seconds}`
end

/Poaster/lib/spawn_input_box.rb:

def spawn_input_box(title, text)
  `kdialog --title "#{title}" --inputbox "#{text}"`
end

/Poaster/lib/ensure_dir_exists.rb:

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:

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:

#!/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, 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.
Isn’t that nice?

My poaster.desktop file looks something like this:

[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:

The ease! The convenience!

To build the new “now burning” incense microblog feature, I created two new pages. /now-burning shows the latest entry:

---
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:"
---
{% 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 %}
	</div>
</article>
<a href="/once-burned/">
	<button type="button">Previous Entries »</button>
</a>

…and /once-burned shows past entries:

---
layout: layouts/base.njk
title: "Nathan Upchurch | Once Burned: Incense I've burning in the past."
structuredData: none
---
{% 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 %}
<a href="/now-burning/">
	<button type="button">Latest »</button>
</a>

…using a post-listing include built specifically for microblogging:

<section class="postlist microblog-list">
	{% 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 %}
	</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.


  1. Yes, I am aware that Kate 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. ↩︎