DEV Community

stepinto.dev
stepinto.dev

Posted on Originally published at stepinto.dev

Everything I Automated on This Blog

This site is a Hugo build pushed to Azure Static Web Apps from a GitHub Actions workflow. Over the past year the workflow and the templates have grown a handful of jobs that do the chores I used to do by hand after publishing a post. None of it is clever, and most of it is under a hundred lines, but each piece came with one gotcha that cost me an afternoon. Here they are, in the order a post meets them.

Publishing on a date I am not at the keyboard

Hugo skips any post whose date is in the future unless you build with --buildFuture. That is the whole scheduling system. I write a post, give it a Tuesday, and push it with everything else. The build ignores it until the date passes.

The gotcha is that the deploy has no schedule trigger. It runs on push, and that is all. A post dated Tuesday appears on the first push after Tuesday, which might be Tuesday if I remember and Thursday if I do not. I have decided this is fine. A cron trigger that rebuilds every morning would fix it in three lines, and I have left it out on purpose, because a rebuild with nothing new in it is the kind of thing that fires for years after you have forgotten it exists.

The dev server runs with --buildFuture so I can read queued posts, and the plan for the quarter lives in a Markdown table that says which file carries which date.

Share images drawn at build time

Every page gets a 1200 by 630 share image rendered by Hugo itself, with no Puppeteer, no Node, and no image service. The partial takes a base PNG from assets/, draws the series name in blue above the title in white, and publishes the result next to the page as og.png:

{{ $filters := slice
     (images.Text ($kicker | upper) (dict "font" $font "size" 26 "color" "#60a5fa" "x" 80 "y" 120))
     (images.Text $title (dict "font" $font "size" $size "color" "#ffffff" "x" 80 "y" 180 "linespacing" 10)) }}
{{ $img := $base.Filter $filters }}
{{ $img = resources.Copy (path.Join (path.Dir .RelPermalink) "og.png") $img }}
Enter fullscreen mode Exit fullscreen mode

images.Text wraps long lines at the image edge, which I did not expect and was pleased to find. What it does not do is shrink the text to fit, so the partial picks a font size from the title length: 64 pixels up to 45 characters, 56 up to 70, and 48 beyond that. The font has to be a TrueType resource, so JetBrains Mono Bold lives in assets/fonts/. The head partial prefers a hand-made cover image if the front matter has one, falls back to the generated one, and only then to the site hero.

A full build produces about 190 of these. They are regenerated only when the title or the base image changes, because Hugo caches image filters by their inputs, so the build cost is a one-off.

Structured data, and the double escaping

Each post carries a BlogPosting block in JSON-LD so search engines get the headline, dates, author and word count without guessing. My first version wrote the JSON by hand in the template and piped each value through jsonify:

<script type="application/ld+json">
{
  "headline": {{ .Title | jsonify }},
  ...
}
</script>
Enter fullscreen mode Exit fullscreen mode

That looks right and is wrong. Hugo templates are Go's html/template, which knows it is inside a <script> element and escapes anything you print there as a JavaScript string. jsonify had already produced a quoted JSON string, so the escaper quoted it again. A title containing a quotation mark came out as:

{"headline": "\"Why \\\"Barely\\\" Is the Right Word\""}
Enter fullscreen mode Exit fullscreen mode

Valid JSON, and every validator accepted it. The headline was just a string that happened to begin and end with quote marks, and because the page source still parses as JSON it is easy to miss until you read a value back. The fix is to build the whole object as a Hugo dict, serialise it once, and mark the result as safe so the script context leaves it alone:

{{ $ld := dict
  "@context" "https://schema.org"
  "@type" "BlogPosting"
  "headline" .Title
  "datePublished" (.Date.Format "2006-01-02")
  "wordCount" .WordCount }}
<script type="application/ld+json">{{ $ld | jsonify | safeJS }}</script>
Enter fullscreen mode Exit fullscreen mode

The rule I took from it: in a Hugo template, never emit JSON field by field. Make the object, serialise it in one go.

Cross-posting to dev.to

The deploy workflow has a job that runs after the build and calls a small Python script against the Forem API. The script reads the Hugo post, converts it, and creates or updates the matching article on dev.to. It carries no state. Before writing anything it lists every article on the account and matches by canonical_url, so there is no mapping file to keep in sync and nothing to break if I rename a post locally.

The conversions are small:

  • The canonical URL points back here, so search credit stays on this domain.
  • Site-relative links and image paths are rewritten to absolute ones. A /posts/foo/ link that works on this site is a dead link on dev.to.
  • Tags are lowercased, stripped to alphanumerics, deduplicated, and cut to four. Dev.to allows four tags made of letters and digits only, so react-native becomes reactnative and a post with six tags loses its last two.
  • The series slug is mapped to the series title from the section's index file.
  • Posts with no description get one from the first paragraph, trimmed to 150 characters.

The job distinguishes between added and modified files in the push. New posts are created. Edited posts are only pushed if an article already exists for them, so fixing a typo in a 2024 post never creates a 2024 post on dev.to with today's date. The script waits three seconds between writes and backs off on a 429, because the API throttles article writes.

The --dry-run flag prints the converted payload and calls nothing. I run it on every new post before pushing, mostly to see what the tag mangling has done.

Newsletter drafts

A second job watches for added posts and creates a Buttondown email for each, with the title and a link. It creates them with status: draft, not about_to_send, so I open Buttondown, add a sentence or two, and send it myself. Fully automatic sending from a CI job felt like the kind of thing that emails every subscriber a half-finished post at 2am.

This job has a bug I know about and have left alone. It finds new posts by diffing the last commit against its parent. A push that carries three commits only sees the last one, so a post added two commits back gets no draft. The dev.to job does this properly, by diffing from the commit the push started at, which GitHub puts in the event payload. One day I will make the newsletter job do the same. Until then I push posts one commit at a time, which I was doing anyway.

Markdown and plain text alongside the HTML

Every post is also published as index.md next to its index.html, from a four-line template that emits the title, description, date and the raw Markdown source. The "Copy as Markdown" button at the top of each post fetches that file and puts it on the clipboard, and prefetches it on hover so the click feels instant. The site root also serves llms.txt, a plain list of every post with a one-line summary and a link to its Markdown, for whatever reads those.

The one thing to know is that Azure Static Web Apps rewrites unknown paths to index.html for single-page apps, so the config has to exclude .md, .txt, .xml and .json from the fallback and declare their MIME types, or your Markdown comes back as your home page.

A hand crank

The workflow also runs on workflow_dispatch. I added that after a deploy failed on the hosting side, with nothing wrong in the commit, and the only way to retry was an empty commit. One button in the Actions tab is better than that.

What I have not automated

Posting to Hacker News, Reddit, Bluesky and LinkedIn is still me, by hand, on the morning of. Each of those wants a different sentence and a different time of day, and the ones I have seen automated read like it. The analytics check is also manual: two counters, three numbers, Monday morning. That is probably the right amount of automation for a blog, which is a thing I write rather than a thing I operate.

Top comments (0)