selfdoc v0.37.1 /Blog Posts
On this page

How to create, manage, and publish blog posts in selfdoc, covering frontmatter, the required directive declaration every post carries, release-generated posts, revision tracking, the post lints check runs, publishing documentation without a release, the declared roster, the home project served at the site root with its curated listing, project retirement, the single canonical hostname the worker redirects every other address to, the four machine-readable files the assembly writes at the site root, the generated deploy workflow and its pins, and the verification every deploy has to pass.

#Blog Posts

selfdoc includes a blog system for publishing chronological content alongside your documentation. Blog posts are Markdown files with YAML frontmatter, stored in a dedicated directory within your project. Posts are unversioned -- they exist outside the multi-version docs system -- and are published to the unified documentation assembly alongside your API reference and guides.

The blog system is part of the selfblog package, which provides the CLI commands (selfblog post new, selfblog post list, selfblog post publish, etc.) and the assembly infrastructure for multi-project documentation sites.

#Configuration

Blog posts require a posts section in your selfdoc.json:

{} json
{
  "posts": {
    "dir": ".selfdoc/posts/",
    "repo": "owner/posts-archive"
  }
}
  • dir -- directory where post Markdown files live (defaults to .selfdoc/posts/ if omitted)
  • repo -- optional GitHub repository for archiving resolved post content

You also need topology.slug configured so that posts are attributed to your project in the unified site:

{} json
{
  "topology": {
    "slug": "myproject"
  },
  "assembly": {
    "repo": "owner/assembly-repo"
  }
}

#Creating Posts

#Manual creation

Use selfblog post new to scaffold a new post file:

$_ bash
selfblog post new --title "My First Post"

This creates a file like .selfdoc/posts/2026-07-29-my-first-post.md with a frontmatter template:

YM yaml
---
title: My First Post
date: 2026-07-29
slug: my-first-post
tags: []
draft: true
directives: false
---

The filename is date-prefixed (YYYY-MM-DD-slug.md). The command errors if a file with that name already exists.

#Release-generated posts

selfblog post generate creates posts automatically from release metadata. This is typically called by release tooling (e.g., rlsbl post-release hooks) rather than manually:

$_ bash
selfblog post generate \
  --from-release \
  --version 1.2.0 \
  --prev-version 1.1.0 \
  --bump-type minor \
  --description "Added widget support" \
  --project-name "My Project" \
  --changelog-file .rlsbl/changes/1.2.0.md \
  --release-url "https://github.com/owner/repo/releases/tag/v1.2.0" \
  --registry-url "https://pypi.org/project/myproject/1.2.0/"

Generated release posts are created with draft: false, directives: false, and release-specific frontmatter fields (version, prev_version, bump_type, release_url, registry_urls). The command also updates the project manifest with the new post entry.

Use --dry-run to preview the generated content without writing files.

#Frontmatter Format

Every post requires YAML frontmatter delimited by ---. Required and optional fields:

#Required fields

Required fields
FieldTypeDescription
titlestringPost title. Must be non-empty.
datestringPublication date in YYYY-MM-DD format.
directivesbooleanWhether the post may carry directive markers. No default: a post that omits the key raises POST006 at discovery.

A post is authored content that may or may not embed code-extracted material, and a post about directive syntax reads exactly like a post that uses it. So the author declares which it is rather than the reader guessing. Declaring directives: false and then writing a marker raises POST007, naming the marker and the line it sits on -- markers inside fenced code blocks and backtick code spans are examples of the syntax, not uses of it, and are never counted. Declaring directives: true resolves the post's directives exactly as a documentation page's are resolved.

Documentation pages carry no such key. The whole docs/ tree is directive territory by construction; only posts declare.

#Optional fields

Optional fields
FieldTypeDefaultDescription
slugstringderived from titleURL-safe identifier. Auto-generated from the title using kebab-case if omitted.
tagslist[]List of tag strings for categorization and search filtering.
draftbooleanfalseWhen true, the post is excluded from publishing and the unified site.
localestringnoneLocale code for localized posts.

#Release-specific fields

These fields are set by selfblog post generate --from-release and are not typically written by hand:

Release-specific fields
FieldTypeDescription
versionstringThe released version number.
prev_versionstringPrevious version for upgrade context.
bump_typestringSemver bump type (patch, minor, major).
release_urlstringURL to the GitHub release page.
registry_urlslistPackage registry URLs (PyPI, npm, etc.).

#Slug immutability

Once a post is published (appears in the manifest), its slug cannot change. The selfblog check command enforces this: if a post file's slug differs from what was recorded in the manifest, it raises a POST005 error. This prevents broken URLs in the unified site.

#Listing Posts

View all discovered posts with:

$_ bash
selfblog post list

Output shows each post's date, title, slug, and draft status:

2026-07-29  Widget Support  (widget-support)
2026-07-15  Getting Started  (getting-started)  [DRAFT]

2 post(s) found.

Posts are sorted newest-first, with same-date posts sorted alphabetically by slug.

#Publishing Posts

selfblog post publish pushes non-draft posts to the documentation assembly:

$_ bash
selfblog post publish

The publish flow:

  1. Discovers all posts in the configured posts directory
  2. Filters out drafts (only non-draft posts are published)
  3. Records content revisions for each post (see Revision Tracking below)
  4. Builds post HTML locally using selfdoc's build pipeline
  5. Pushes built HTML and the post-manifest to the assembly repo via the GitHub Git Data API (atomic commit, no clone required)
  6. Dispatches a shared-only rebuild to regenerate cross-project elements (blog index, RSS feed, sitemap)
  7. Optionally archives resolved Markdown to a separate posts repository (if posts.repo is configured)

Publishing is separate from a full documentation release. You can publish new posts without releasing a new version of your software -- the assembly workflow rebuilds only the posts subtree and shared elements.

#Revision Tracking

selfdoc tracks content revisions for blog posts via a sidecar file at .selfdoc/revisions.json. Revision tracking is automatic -- it happens during selfblog post publish.

#How it works

Each post's body content (with frontmatter stripped) is hashed using SHA-256. Whitespace is normalized before hashing so that insignificant formatting changes do not trigger false revisions. A new revision entry is appended only when the content hash changes compared to the most recent revision.

Each revision records:

  • content_hash -- SHA-256 of the normalized body text
  • timestamp -- UTC ISO-8601 timestamp of when the revision was recorded
  • summary -- optional human-readable description of the change

The revisions file structure:

{} json
{
  "posts": {
    "my-first-post": {
      "revisions": [
        {
          "content_hash": "a1b2c3...",
          "timestamp": "2026-07-29T12:00:00+00:00",
          "summary": "Initial publish"
        },
        {
          "content_hash": "d4e5f6...",
          "timestamp": "2026-08-05T09:30:00+00:00"
        }
      ]
    }
  }
}

The revisions sidecar is published to the assembly alongside the post-manifest, stored as manifests/{slug}-revisions.json.

#Post Validation

selfblog check runs five post-specific validation checks:

Post Validation
CodeDescription
POST001Missing date field in frontmatter
POST002Missing or empty title field
POST003date field is not in YYYY-MM-DD format
POST004Duplicate slug across posts
POST005Slug changed after publication (immutability violation)

These checks run as part of selfblog check for standalone blog projects. For unified docs-site projects, post checks run alongside the full documentation validation suite.

Suppress specific checks with --ignore:

$_ bash
selfblog check --ignore POST003

#Integration with the Assembly

Blog posts integrate into the unified multi-project documentation site through the assembly system. The assembly is a GitHub repository that collects built documentation from multiple projects and deploys the combined site.

#How posts reach the unified site

  1. Per-project build: selfblog build --target posts builds post HTML and generates a post-manifest.json containing metadata for all non-draft posts.
  1. Assembly push: selfblog post publish pushes each built post into site/blog/{post-slug}/ in the assembly repo -- the site level, under no project slug -- and the post-manifest into manifests/{slug}-posts.json. The listing page the build renders for the project's own standalone site is not pushed: the assembled site's blog index is generated from every project's manifests.
  1. Shared regeneration: The assembly workflow runs selfblog assembly integrate, which grafts the dispatched build into the assembly tree and then regenerates the shared cross-project elements from all per-project manifests and post overlays:

- A blog index page listing all posts across all projects, sorted newest-first - An Atom RSS feed aggregating posts from every project - An XML sitemap including all post URLs - A navigation JSON file with project and blog links - A homepage listing all projects

  1. Post overlay merging: Post-manifest files (*-posts.json) act as overlays. When the shared elements generator finds a post overlay for a project, it replaces that project's posts in the base manifest with the overlay's posts, which is why deleting a post and republishing removes it from the site. A full build does not delete the overlay -- it folds its own posts into it, so a release's posts appear without the overlay's out-of-band posts being lost.

#Post URLs in the unified site

Posts are served at /blog/{post-slug}/ in the assembled site, with no project segment. For example, a post with slug widget-support is available at /blog/widget-support/ whichever project wrote it.

The project a post came from is a row on the blog index, not part of its address: the site has one blog, and every project's posts share its slug namespace. Two projects publishing the same post slug is a hard error -- refused when the assembly merges their manifests, and refused again by the graft before it can overwrite the other project's file.

#One hostname

A Cloudflare Pages project can carry several custom domains, and all of them serve the same site. Left alone that means every page is reachable at more than one address, which splits ranking signals between duplicates. The assembly resolves this to one hostname: topology.docs_base, and nothing else serves content.

One hostname
RequestResult
canonical host, any pathserved
any other host, any path301 to the same path on the canonical host
<topology.legacy_blog_host>/<path>301 to <docs_base>/blog/<path>

The retired blog subdomain is the one host that does not map to the same path: its whole document space was the blog, so blog.example.com/hello/ is <docs_base>/blog/hello/. Mapping it to the same path would send every live post link to the site root, where nothing answers.

Every redirect is single-hop and is implemented in the _worker.js that the shared-element generator writes into the site output (selfblog assembly integrate during a deploy, selfblog assembly generate-shared when run by hand). The worker takes its target from --canonical-base (the generated deploy workflow passes topology.docs_base to integrate) and its retired-subdomain prefix from --legacy-blog-host (topology.legacy_blog_host, omitted when no such subdomain exists). Nothing is hardcoded, and --canonical-base has no default -- generate-shared fails without it.

#Historical addresses

The worker also carries the redirect map for the address schemes the site has retired. It is generated as data from the manifests at shared-generation time -- the assembly knows every project slug and every post slug -- so a path that merely looks historical without naming a real one is not redirected at all: it falls through to the root 404, which is the honest answer for an address that never existed.

Historical addresses
Retired shapeResult
/<slug>/<locale>/<version>/<rest>301 to /<slug>/<rest>
/<slug>/posts/<post>/301 to /blog/<post>/
/<slug>/<page>/served -- this is the current scheme

The version segment is not preserved: any version collapses to the stable address. Archived versions are still served at /<slug>/v/<version>/, but an old deep link is far more likely to want the page as it is now than the page as it was at whatever version happened to be current when the URL was copied.

A historical path arriving on a non-canonical host is resolved together with the host, so it still costs exactly one 301 rather than two.

The map is patterns plus two sets of names, never one entry per page, so the worker's size tracks the number of projects and posts rather than the size of the site.

The shared homepage and blog index also carry a rel="canonical" link pointing at the canonical host, so a crawler that reaches them through a non-canonical domain still records the canonical address.

Set topology.posts_base to the same canonical blog URL. It is a path on the docs site, not a separate host.

#Operator steps (outside selfblog)

Two pieces of this topology live on platform dashboards and are not automated:

  1. Custom domains. Every hostname the worker redirects from must be attached to the assembly's Cloudflare Pages project, otherwise the worker never runs for it and the request does not reach the redirect. Add them under the Pages project's Custom domains tab.
  2. Search Console. Register a Domain property for the root domain rather than one URL-prefix property per subdomain. A Domain property covers the canonical host, the retired blog subdomain, and the apex in a single property, so the consolidation is visible as redirects instead of appearing as unrelated sites competing with each other.

#The machine-readable files at the site root

Every constituent build writes a robots.txt, an llms.txt, a sitemap.xml and a 404.html at its own output root, where the graft buries them under <slug>/ and no crawler reads them. The four the site serves are generated once, for the whole site, by the shared-element generator.

  • **sitemap.xml** lists every project's pages and every post. Each <loc> is absolute under the canonical base -- the sitemap protocol has no relative form, so the generator takes the canonical base for it regardless of what --docs-base says, and refuses an empty or root-relative one.
  • **robots.txt** names that sitemap by absolute URL, and carries the same crawler policy the per-project template declares. Both read one declaration (selfdoc_core.build.ROBOTS_AGENTS), so a crawler the site allows cannot be one its projects disallow.
  • **llms.txt composes the per-project files by reference**: one line per project, with its name, a link to its own llms.txt, and the one-line description from its manifest, plus a link to the blog. It never inlines their contents -- an inlined copy would be a second, staler rendering of a document its owner republishes on its own deploys.
  • **404.html** is what Cloudflare Pages serves, with a 404 status, for an address that matches no asset. Its body is deliberately not the front page's: an unknown address that renders the home page is a soft 404, which a crawler indexes as a duplicate of the site root and a reader mistakes for having arrived somewhere. It links home, the project listing and the blog.

The home project is left out of llms.txt for the same reason it is left out of the listing: it is the site root the file is served from, not one of the projects it points at.

#The deploy workflow is a generated artifact

The assembly repo's .github/workflows/deploy.yml is generated from the project's selfdoc.json, not hand-written. It is deliberately thin: check out the assembly, install the toolchain, clone the dispatched project, run selfblog assembly integrate, deploy the result to Cloudflare Pages. Every decision the deploy makes lives in the command, so it can be tested without dispatching a real deploy.

selfblog assembly init writes that file when the assembly repo is created, and **selfblog assembly sync-workflow rewrites it afterwards**. Run it (or let the release path run it) whenever selfblog changes: it regenerates the workflow, compares it against the deployed copy, and pushes only when the bytes differ. Without it the deployed workflow stays frozen at whatever the template said the day the repo was created.

The workflow's install line pins every tool it installs -- selfdoc, selfblog and pagefind. Each pin is rewritten by every sync-workflow run, so they track releases rather than capping them, and a deploy can never pick up a tool whose behavior the deployed workflow does not know about. selfblog is pinned to the selfblog that generated the file and selfdoc to the selfdoc installed alongside it; pagefind is a CI-only tool that nothing here installs, so its pin is PyPI's current release at sync time. --pin-version, --pin-selfdoc and --pin-pagefind name any of them explicitly.

Before writing anything, sync-workflow asks PyPI whether each pinned version is actually published, and refuses the whole run when one is not. The default selfblog pin is the running selfblog, which in a checkout is an editable install sitting ahead of the registry -- writing that pin would produce a workflow whose pip install cannot resolve, and the failure would surface on the assembly repository at the next dispatch instead of here.

#Verification before the deploy

The deploy reads the tree it assembled before it commits or pushes any of it. selfblog assembly integrate runs the verification itself, after the search index and before the commit, so a tree that fails is a tree that never reaches the site. There is no flag that turns it off.

What it asserts, each failure naming its offender:

Verification before the deploy
PropertyWhat a failure means
Roster, site/ subtrees and manifests/ name the same projectsAn undeclared subtree, a declared project with nothing to serve, or an orphan manifest of any kind
Each manifest describes the tree beside itIts slug names another directory, its version disagrees with the pages, or its current version is sitting in the archive tree
Every page and post a manifest lists was emittedA listing, feed or sitemap row that leads to a 404
The shared artifacts exist, parse, and say what they are forA missing or malformed front page, project listing, blog index, nav.json, sitemap, feed, robots.txt, llms.txt, root 404, or an empty search index; a 404 whose body repeats the front page or offers no way back; a robots.txt naming no sitemap or one the tree does not carry; an llms.txt missing a declared project
Every reference resolvesAn internal link, canonical, sitemap entry or feed link naming a file the assembly did not write
Every page is addressableA page with no title, or a canonical that is not under the site's canonical base
Nothing half-built or per-project leaked inAn unresolved directive marker, or a project's own _headers, _redirects, _worker.js or pre-compressed copies
Cross-project links land somewhereA link from one project's page into a page no other project publishes

selfblog assembly verify --assembly-dir <checkout> --canonical-base <url> runs the same assertions by hand against a checkout. It is read-only.

External links are checked only on pages the assembly declares, in a committed outbound.toml at the assembly root:

TM toml
cache_days = 7

[[page]]
path = "index.html"

[[page]]
path = "blog/index.html"

Both keys are required and unknown keys are refused. Results are stored by address in outbound-cache.json and trusted for cache_days, so a deploy that changes nothing sends no requests. **With no outbound.toml there is no outbound check**, and every run says so on stderr rather than passing quietly.

#Posts-only vs full builds

The assembly workflow distinguishes between posts-only and full documentation dispatches:

  • Posts-only (scope: "posts"): rebuilds only the posts subtree, preserving the rest of the project's documentation. Uses selfblog build --target posts.
  • Full build: rebuilds all documentation including posts, and folds its posts into the post overlay.
  • Shared-only (scope: "shared-only"): regenerates only cross-project elements (blog index, feed, sitemap) without rebuilding any project's documentation. Used after post publishes, documentation publishes and retirements.

Every scope reconciles membership first: whatever else a dispatch is doing, it makes the tree match roster.toml before it makes it match the build.

#Publishing Without a Release

Two commands put content on the live site with no tag and no release. Both build locally, push straight into the assembly repository through the Git Data API, and then dispatch a shared-only rebuild.

$_ bash
selfblog post publish    # non-draft posts
selfblog docs publish    # the project's documentation

docs publish builds the docs the same way the deploy does, applies the same deploy-artifact exclusions (_headers, _redirects, _worker.js, .gz, .br), and pushes the project's subtree, its manifest, its published-file record and its membership entry in one commit. Content travels as bytes, so images and fonts survive intact. Deletions travel with it: a page the project published before and no longer builds is removed in the same commit.

Both commands are consequential -- they make locally-authored writing publicly readable -- so they prompt unless --approve-consequential is passed. Neither can create membership: publishing into a slug roster.toml does not declare is a hard error naming the block that would have to exist.

#What a build owns

A full build used to replace site/{slug}/ wholesale, which meant a release destroyed anything published into that subtree since the last one. It prunes to its own output instead.

Every publisher -- the release-time integrate, docs publish, post publish -- records the paths it produced in manifests/{slug}-files.json:

{} json
{
  "schema_version": 2,
  "slug": "myproject",
  "owners": {
    "release": ["myproject/index.html", "myproject/reference/index.html"],
    "docs": ["myproject/index.html", "myproject/hotfix/index.html"],
    "posts": ["blog/widget-support/index.html"]
  }
}

Every path is relative to site/, so one record names both the project's own pages, under its slug, and its posts, at the site level under blog/. That single namespace is also what lets one project's claim be checked against another's: the graft refuses to write over a post path another project's record claims.

A publisher removes a path only when it produced that path before and does not produce it now, and never when another publisher currently claims it. So:

What a build owns
SituationOutcome
A page the new build droppedPruned
A page published between releases that the build never producedKept
A page both a release and a documentation publish produceRefreshed by whichever ran last
A post published out of band, on a release that does not carry itKept

An absent record means nobody has published anything for that project yet, so nothing is removed -- a publisher never deletes what it cannot show it wrote.

#Membership: the Roster

The projects the unified site serves are declared in roster.toml, hand-edited and committed in the assembly repository:

TM toml
home = "mysite"

[[project]]
slug = "mysite"
repo = "owner/mysite"

[[project]]
slug = "myproject"
repo = "owner/myproject"

[[project]]
slug = "otherproject"
repo = "owner/otherproject"

slug and repo are both required, unknown keys are a hard error, a duplicate slug is a hard error, and a slug that collides with one of the assembly's own directories (blog, projects, pagefind) is a hard error. A missing file is a hard error too: membership has no empty default, because a deploy that guessed at it would be accumulating membership again.

home is required as well, and names exactly one of the declared projects -- see below.

#The home project

One declared project is the site's front page. home = "<slug>" says which, and there is no default: a roster with no home, or a home naming a slug no [[project]] block declares, is a hard error. A site needs a front page, and selfblog will not pick one.

The home project is an ordinary project -- a real repository that dispatches its own deploys like any other. Being home changes four things:

  • Its pages are emitted at the site root. index.md becomes /, cv.md becomes /cv/. No project segment, no locale segment, no version segment, and no archives.
  • **It is left out of the generated listing and out of nav.json.** The front page does not list itself.
  • The addresses the assembly owns are refused to it. A page that would land on blog/, projects/, v/ or pagefind/ is a hard error at the graft and again in assembly verify, which also refuses a leftover site/<home>/ subtree and a home directory that shadows another project's slug.
  • It cannot be retired. Retiring the front page would leave the site without one; name another project home first.

The site-wide artifacts the home project's own build writes for standalone hosting -- sitemap.xml, robots.txt, 404.html, feed.xml, llms.txt, nav.json, the routing files and the compressed variants -- are dropped on the way in. The assembly writes the ones the site serves.

#The curated listing

The home project declares which projects the site shows, and how, in docs/projects.toml:

TM toml
[[category]]
name = "Frameworks"

  [[category.project]]
  slug = "myproject"
  blurb = "One line about it."

[[category]]
name = "Elsewhere"

  [[category.project]]
  slug = "thing"
  name = "Thing"
  blurb = "A project with no docs section here."
  url = "https://example.org/thing"

The listing is content, so it lives with the content. An entry without url names a project the site serves: its display name and version come from that project's manifest, which is why declaring a name for one is refused -- there would be two sources for the same fact. An entry with url is external and therefore carries its own name.

Validation is strict: unknown keys, a missing or empty blurb, a category with no entries, a duplicate slug, and a listed slug the assembly has no manifest for are each a hard error naming the offender. A roster project the listing leaves out is legal -- curation is selection.

The deploy copies the file in beside the manifests, and both renderings read it: the generated /projects/ page and the front page's cards.

#Site-level directives

Two directives are available to the home project's pages:

Site-level directives
DirectiveRenders
:-: projects-cardsthe curated listing, with each project's live version
:-: blog-highlights limit="N"the N most recent posts across every project

They resolve twice, from the same code. Once at build time -- which is why the home project builds through selfblog build --target home --site-manifests <dir> --docs-base <url> rather than through selfdoc build: a version badge is read from the assembly's manifests, and no project's own repository holds them. Without that context the command refuses to build at all, naming what is missing; a plain selfdoc build refuses too, on the unknown directive. Neither ever emits an empty region.

Then again on every deploy, including deploys the home project has nothing to do with. Each resolved region is left in the emitted HTML inside a <selfblog-region> element, and the shared-element generator rewrites its contents from the current manifests. That is what keeps a front-page version badge current when the project it names releases: the prose and the design stay authored, the mechanical parts cannot go stale.

projects.json beside it is derived state, rewritten by every deploy: it records what each declared project last deployed (repo, ref, version) and is what selfblog assembly rebuild replays. It cannot gain a key on its own -- a dispatch for a slug the roster does not declare is refused, and a slug the roster declares under a different repository is refused too.

Every deploy reconciles the tree to the declaration. A project that is no longer declared loses its site subtree, all of its manifest kinds, its projects.json record, and -- because the search index is rebuilt from scratch whenever anything went -- its entries in the index.

#Retiring a project

$_ bash
selfblog assembly retire --slug oldproject

One operation: the [[project]] block leaves the roster and, in the same commit, every path the project owns is deleted; the shared-only dispatch that follows regenerates the listing, blog index, feed, sitemap and search index without it. It is consequential -- the only command in either CLI that removes published content -- and retiring a slug the roster does not declare is a hard error naming the ones it does.

#Building Posts Locally

Preview posts locally before publishing:

$_ bash
# Build posts only (no versioned docs)
selfblog build --target posts

# Include draft posts in the build
selfblog build --target posts --drafts

Built HTML is written to the configured output directory: each post at docs/_build/blog/{post-slug}/, plus a listing page at docs/_build/blog/ for the project's own standalone site.

Search