Syndication Links Without Frontmatter Editing

Category: Development

Tags: indieweb, astro


The Microformats2 spec defines u-syndication — a link that marks where a post has been copied to. When a Bluesky post exists for an article, that link should appear on the article automatically. Editing frontmatter by hand after every deploy is not a workflow that scales past the second post. This one has a clean answer: the syndication state file is already the source of truth — let the build read it.

The setup

  • Site: Astro 6 static build, deployed via shell script.
  • Syndication state: .bluesky-posted.json committed to the repo root — a map of canonical URL → Bluesky post URL.
  • Frontmatter: a syndication array for manually added targets (e.g. a newsletter), nothing else.

The problem with manual syndication

The obvious approach is to write the Bluesky URL into the article’s frontmatter after it’s been posted:

syndication:
  - https://bsky.app/profile/adrian-altner.de/post/3mhqb4t6ceu2w

This works, but it requires a second commit after every syndication run: one to publish the article, one to add the Bluesky URL back into its source. The two sources of truth also drift — edit the article later and it’s easy to forget the syndication line exists.

The state file as the source of truth

Implementation: .bluesky-posted.json already has the information, and it’s already committed after each run. Astro can import it directly at build time:

import bskyPosted from "../../../.bluesky-posted.json";

The file is a map of canonical URL → Bluesky post URL:

{
  "https://adrian-altner.de/articles/2026/03/23/joining-the-indieweb/": "https://bsky.app/profile/adrian-altner.de/post/3mhqb4t6ceu2w"
}

Deriving syndication URLs at build time

On the article page, build the canonical URL first, then look up the Bluesky post URL from the imported JSON:

const articleUrl = new URL(`/articles/${post.id}/`, Astro.site).toString();
const bskyPostUrl =
  (bskyPosted as Record<string, string | null>)[articleUrl] ?? null;
const syndicationUrls: string[] = [
  ...(post.data.syndication ?? []),
  ...(bskyPostUrl ? [bskyPostUrl] : []),
];

post.data.syndication stays for manually added URLs — Mastodon, a newsletter, anything else. The Bluesky URL is appended only if it exists in the state file. The result is a unified list, no duplication.

Notes use the same pattern with noteUrl in place of articleUrl.

syndicationUrls drives the “Also on:” line below each post:

{syndicationUrls.length > 0 && (
  <p class="syndication-links">
    Also on:{" "}
    {syndicationUrls.map((url, i) => {
      const host = new URL(url).hostname.replace("www.", "");
      return (
        <>
          {i > 0 && ", "}
          <a class="u-syndication" href={url} rel="syndication noopener" target="_blank">
            {host}
          </a>
        </>
      );
    })}
  </p>
)}

The hostname is extracted from the URL for the link text — bsky.app labels Bluesky posts automatically. Adding another syndication target later would show its hostname without any template changes.

Trailing slashes matter

Problem: webmention.io stores URLs exactly as submitted. If Bridgy sends a webmention for https://adrian-altner.de/articles/.../hello-world/ (with trailing slash), the webmention component has to query with the same URL.

Implementation: Astro’s URL construction with a trailing slash in the template literal keeps this consistent:

const articleUrl = new URL(`/articles/${post.id}/`, Astro.site).toString();
// → "https://adrian-altner.de/articles/2025/12/01/hello-world/"

Solution: lookup keys in .bluesky-posted.json and query strings sent to webmention.io are byte-for-byte identical. Without the trailing slash, a lookup for /hello-world returns zero results even when mentions exist — and you spend half an hour debugging “webmentions broken”.

No frontmatter edits required

The full flow: deploy → syndication script posts to Bluesky → state file updated → state file committed and pushed → next build reads state → syndication links appear on every page that has a Bluesky post.

Article frontmatter is touched exactly once: at initial publish.

What to take away

  • Let the syndication state file be the source of truth. Frontmatter editing doesn’t scale, and it desyncs silently.
  • Import JSON directly into Astro components. Build-time imports are type-safe and don’t cost anything at runtime.
  • Merge with post.data.syndication. Manual entries and auto-derived URLs should flow through the same array.
  • Normalise on trailing slashes everywhere. Canonical URL in the lookup, in webmention.io, in the sitemap — one format, no exceptions.
  • Render hostnames, not URLs. The list stays readable and new targets slot in without template changes.