Idunworks Issue #01
I can has lingo?

My blog had no title, and nobody told me

Idunworks
Author

What this is about. I wanted my blog posts to show up on LinkedIn, Bluesky, Mastodon and the rest without copy-pasting each one by hand. Before any of that, my AI found that my blog pages had no <title>, no description and no preview image. Every link I had ever shared was a naked URL.

Why. If you run your own CMS, the stuff a platform like WordPress gives you for free is stuff you have to remember yourself. Nobody reminds you, because the page looks fine in a browser. This post is how we found the hole, filled it across all my sites, and built a small posting tool that never posts unless I say so.

The ask

Here's what I typed, typos included:

Investigate the possibility and if viable, make a plan as how to publish (parts of) the posts into social media, like Linkedin, Instagram, Fb, tiktok(?) and others. Make a plan and rade according to difficulty and small money, if not free gains.

I expected a comparison of APIs. What I got first was a bug report about my own blog.

Step zero: curl your own page

The AI didn't start with LinkedIn's docs. It fetched one of my live posts and looked at the <head>. There was nothing in it. No <title>, no meta description, no Open Graph tags, no Twitter card. And no RSS feed either: /rss.xml, /feed and /rss.xml all answered 404.

So a link to my post pasted into LinkedIn, Slack or a chat app showed up as just the URL. No headline, no picture, no reason to click.

The annoying part: the CMS already stored all of it. Every page has fields for a meta title, a meta description and a preview image. The blog template simply never rendered them. One of my sites, Rebel Kayaks, had its own head component for trip-report share cards, written months ago. The other sites never got one, and I never noticed, because a browser tab with a URL in it looks perfectly normal when you're the one who typed the URL.

The ratings

The plan came back as a table, rated 1 (an hour) to 5 (days, plus approval processes). Abbreviated:

Channel

Difficulty

Cost

Worth it?

Title, preview tags, RSS

1–2

free

Do first. Every share everywhere gets better, and so does search

LinkedIn, personal profile

2

free

The main channel for my readers

dev.to, whole post with a canonical link

2

free

Reaches developers, and Google still credits my site

Bluesky, Mastodon

1

free

Cheapest APIs there are

Facebook Page, Threads

3

free

Later. Small audience for posts about databases

Instagram

4

free

Needs images, can't carry links

X

2

about $0.20 per post with a link

Pasting by hand is free

TikTok

5

free

Skip for the blog. Maybe for the games

I said "Yes, you may commence 1 through 3": the tags, a per-post "social kit", and one-command posting for the four easy platforms.

Step 1: give every post a head

The fix is one component that renders the fields the CMS already has: title, description, canonical URL, Open Graph and Twitter tags, the publish date and the post's tags. It went into the shared UI package, so every site gets it.

The fiddly part is the preview image. Most of my posts don't have one set, so it falls back:

  1. the post's own preview image, if set

  2. else the first image in the page's modules (hero, gallery and so on)

  3. else the first <img> in the post's text

SVGs are skipped at every step. Social cards don't render them, and I have a lot of SVG art, so without that rule half my previews would be broken pictures.

The RSS feed lists published posts only, never drafts or scheduled ones, whoever is logged in when it's fetched. A feed is the kind of thing that gets cached and mirrored by services you've never heard of, so it's the wrong place to leak a draft.

One small war story: my sites get shared files through a sync script that never overwrites a file a site already has. That's on purpose, because sites customise things. It also meant the new component had to be copied into each site by hand, and two of them have their own blog page that needed it wired in. A sync that's safe by default is also a sync that's quietly incomplete by default.

Step 2: the social kit

For each post, a script writes one file with a section per platform. My AI writes the text in my voice, cut only from the already-redacted post, and I edit it. A check command prints the final text for each platform and fails on anything too long.

Every platform counts differently, which is where the fun is:

  • LinkedIn allows 3,000 characters, but only the first ~210 show before "see more". The hook has to fit there.

  • Bluesky allows 300 graphemes, so an emoji counts as one.

  • Mastodon allows 500, and every link counts as 23 characters, however long it is. X does the same at 280.

  • Instagram can't carry a link at all.

Every link gets tracking tags (utm_source=linkedin and so on), so my own analytics tool, Muntra, shows which channel actually brings readers. The AI checked first that Muntra keeps those tags, because I had just made it strip other query strings for privacy.

Step 3: posting, but only when I say so

social-publish.mjs posts a kit to LinkedIn, Bluesky, Mastodon and dev.to (as a draft there). By default it's a dry run: it prints exactly what each platform would receive and sends nothing. Only --go posts, and the rule written into the repo is that --go runs when I say so, for that post. Never on the AI's own initiative, and never for several posts at once.

A few things I learned on the way:

Not every platform fetches your preview. Mastodon reads the page and builds the card itself. LinkedIn's and Bluesky's APIs don't: you have to hand them the title, the description and the image yourself. So the script reads my live page's preview tags and uploads the image. That's also why it refuses to post unless the live page answers 200 and has an og:title. Platforms cache previews for days, so a link shared one hour too early stays ugly for a week.

Bluesky counts in bytes. Links and hashtags in a Bluesky post are marked by byte position in the UTF-8 text, not by character. One "å" before a link shifts it by a byte, and the link lands one letter off. There's a test with Swedish characters for exactly that.

LinkedIn's text is a markup language. In the posts API a handful of characters, brackets and # among them, mean something. A plain bracket in your text has to be escaped, and a hashtag has to be written in LinkedIn's own little syntax to become a real hashtag.

My own server lies about images. The CMS serves .webp uploads as application/octet-stream. The script now looks at the file's first bytes to tell PNG, JPEG, WebP and GIF apart, instead of trusting the header. That's a fix for my script, not for the platforms' crawlers, so for preview images I stick to PNG or JPEG.

Logins live in the Keychain. No tokens in files, nothing in the chat. LinkedIn's token lasts 60 days, and status warns a week before it runs out.

What the AI got wrong

The script takes a file name, and optionally --to linkedin,bluesky. To find the file name it filtered out the argument after --to. When there was no --to, the "index after --to" was −1 + 1 = 0, so it filtered out argument number 0. Which was the file name. The dry run caught it before anything was posted, and that is the whole argument for dry runs in one line.

Also honest: the real API calls haven't been run yet, because none of my logins are stored yet. The unit tests, the Keychain storage and the live-page check are tested. The first real post per platform will go out one platform at a time, and I'll look at it on the platform before doing the next. The LinkedIn API version is pinned in one line, taken from vendor reports rather than LinkedIn's own docs, so if LinkedIn has retired it, the first post fails with an HTTP 426 and that line gets bumped.

What happened, how we fixed it, and how else it could be done

  • No title, description or preview tags on blog pages. Fixed with one shared head component that renders what the CMS already stored, with a sensible image fallback.

  • No RSS feed. Added one, published posts only.

  • Writing a post per platform by hand. A social kit per post, with length checks per platform.

  • Posting by hand to four places. One command, dry run by default, live-page check, a log so nothing posts twice, and only I say go.

Other ways to do it:

Option

Better at

Costs

Our own scripts (this)

Free, fits my "I publish, not the bot" rule, text in my voice

We keep up with API changes ourselves

Buffer

A calendar and analytics, they deal with API churn

Free for 3 channels, then about $5–6 per channel per month

Postiz (open source, self-hosted)

Many platforms, free, my data

One more service to secure, back up and patch

Zapier or IFTTT from the RSS feed

No code at all

Generic "New post: …" text that reads like spam

A posting API like Ayrshare

One API for everything, including TikTok and Instagram

A monthly fee, and every post goes through a third party

If you just want to post more, Buffer is the honest answer. I wanted the text in my voice and a hard stop before anything goes out, and that was easier to build than to configure.

Before you share a link from your own site

  1. curl the page and read the <head>. Is there a <title>? An og:title? An og:image that isn't an SVG?

  2. Paste the URL into LinkedIn's Post Inspector. It shows the card and refreshes the cached one.

  3. Check that the image is served with a real image type, not application/octet-stream.

  4. Make sure your RSS feed can't show a draft.

  5. Share only after the page is live with its tags. The platforms remember the first version for a long time.

The first post to go out this way will be the one about my database being on the internet. If you see it on LinkedIn with a proper picture, it worked.

Log in to like this article, or create an account .
0 reads

Comments

Log in to post comments. Log in
No comments yet. Be the first to comment.