Developer-facing engineering blog for Sailing Naturali. Built with Jekyll, served by GitHub Pages at engineering.sailingnaturali.com.
It's the developer-facing sibling of the exec-facing Substack: code-heavy posts
about building the boat-agent / marine-AI-ops stack (Home Assistant, SignalK,
MCP servers, local LLM, NMEA marine data), aimed at the self-hosted / HA / maker
crowd. Design rationale lives in the planning repo:
docs/superpowers/specs/2026-06-01-engineering-blog-and-scribe-agent-design.md.
- Static-site generator: Jekyll (
minimatheme). - Build: native GitHub Pages build — pushing to
mainrebuilds and publishes. No GitHub Actions workflow. Plugins are limited to the Pages whitelist; we usejekyll-seo-tag,jekyll-feed, andjekyll-sitemap. - Domain: the
CNAMEfile pinsengineering.sailingnaturali.com. DNS is aCNAME engineering → sailingnaturali.github.iorecord.
Posts are Markdown files in _posts/, named YYYY-MM-DD-slug.md. Front matter:
---
layout: post
title: "Clear and useful, one topic, ~8–12 words — keep the core keyword, drop the long tail"
description: "1–2 sentence SEO description. Name the real symptoms and versions — this is where the full googled error strings live."
date: 2026-06-01
tags: [homeassistant, selfhosted, ai]
---URLs are /:title/ (the slug from the filename), so build the slug from a full
SEO headline — pack in the error strings and versions there. The slug and
description carry the SEO load, which frees title: to be clear and useful (one
topic, ~8–12 words, core keyword in, long tail out). Slug = SEO; title = human.
jekyll-seo-tag derives the canonical URL automatically — don't set one unless
the post is syndicated and canonical lives elsewhere.
- Code first. Every config, command, error, and change is a copy-pasteable block, never described in prose.
- broke → tried → fixed. The "what we tried (and why it failed)" beat is mandatory — it's the highest-trust, highest-SEO part of the post.
- Direct, technical, no marketing fluff. Close with a short, non-salesy line connecting back to the project.
Drafts are produced on-demand by the Scribe agent (lives in the planning
repo at .claude/agents/scribe.md) from finished engineering work and
docs/agent-lessons.md. The Scribe opens a PR against this repo; a human reviews
voice/accuracy and merges. Never reproduces anything from the private
infrastructure repo.
Published posts are mirrored to dev.to as a distribution
channel; the blog stays canonical (each dev.to article sets canonical_url
back here). .github/workflows/crosspost.yml runs after a successful Pages
deploy and calls bin/crosspost-devto, which:
- lists what's already on dev.to and creates only the missing posts (idempotent, create-only — re-runs are no-ops, edits are not re-pushed),
- sets
canonical_url, derives ≤4 dev.to tags from the post's front matter (first four, lowercased, non-alphanumerics stripped), and rewrites root-relative links to absolute.
dev.to throttles article creation (300-second windows, strict for new accounts),
so a single run only gets a couple of posts through before a 429. That's treated
as "done for now" — the run logs what's left and exits successfully — and an
hourly schedule: trigger drains any backlog a few posts at a time. Steady state
(one new post per deploy) goes out on the after-deploy run and never trips the
limit.
One-time setup: generate a dev.to API key (dev.to → Settings → Extensions →
API Keys) and add it as the repo secret DEVTO_API_KEY
(gh secret set DEVTO_API_KEY). Dry-run anytime via the workflow's manual
"Run workflow" button (check dry_run) or locally:
DEVTO_API_KEY=... bin/crosspost-devto --dry-runLogic lives in lib/devto/; tests in test/devto/ run with
ruby -e 'Dir.glob("test/**/*_test.rb").each { |f| require File.expand_path(f) }'.
bundle install
bundle exec jekyll serve # http://localhost:4000