bx-sites-blog-versioning-i18n
Set up and write a blog, versioned docs, and translated (i18n) locales in bx-sites (ortus-boxlang/bx-sites) - docs/blog/posts frontmatter and authors.yml, post:new, categories/archives/RSS, docs/versions/<name> and version:new, docs/i18n/<code> and i18n:new, composing versions with locales, theme-chrome translation strings, and redirects (frontmatter redirect_from and bxsites.yaml's redirects). Use this whenever a user wants to add a blog post, cut a new docs version, add a translated locale, or keep an old URL working after a page moves.
BxSites Blog, Versioning, i18n & Redirects
All three are convention over configuration - no bxsites.yaml key
turns them on, just a folder.
Blog
Drop posts under docs/blog/posts/ and BxSites builds /blog/ (paginated),
a category page per category, a year-archive page per calendar year, an
author page per author, RSS feeds, and /blog/stats/ - zero config. A
project with no docs/blog/posts/ folder simply has no blog.
Scaffolding a post
bxSites post:new --title="My New Post" [--slug=] [--date=] [--authors=] [--categories=] [--tags=] [--draft]
--draft defaults true; pass --!draft to publish immediately.
Writing a post
Every .md under docs/blog/posts/, any depth, is a post. Subfolders are
purely for editing convenience - a post's URL/sort/archive are all derived
from frontmatter, never from where the file lives:
---
title: Announcing BoxLang 2.0
date: 2026-08-15
authors: [lmajano]
categories: [Releases]
tags: [boxlang, release]
summary: A faster runtime, a smaller footprint, and a few surprises.
image: assets/blog/boxlang-2-cover.png
---
A short intro paragraph or two.
<!-- more -->
The rest of the post - left out of the excerpt on /blog/ and category
pages, still renders in full on the post's own page.
date(required) - sets sort order (newest first) and<pubDate>authors- ids matchingdocs/blog/authors.yml, or a plain unlinked namecategories- taxonomy, each gets its own/blog/category/<slug>/page + feed. Unrelated totags.tags- the usual site-wide tags (badges, feeds/tags/)summary- one-line excerpt for lists/RSS (falls back to a plain-text truncation if omitted and no<!-- more -->)image- featured image (docs/assets/-relative or full URL); also becomesog:image/Twitter card unlessogImageoverrides it; gets the same responsive<picture>/WebP treatment as any image (seebx-sites-markdown)slug- overrides/blog/<slug>/(defaults from filename)draft: true- excluded from a realbuildentirely;servepreviews it with a visible "π§ Draft" banner so you can proofread locally- Every ordinary page frontmatter key (
icon,description,ogImage,toc) also works on a post - seebx-sites-getting-started
docs/assets/blog/ is just a conventional subfolder for post
covers/author photos - not enforced, any docs/assets/** path works.
Authors
docs/blog/authors.yml, optional, one entry per id, referenced by a post's
authors list:
lmajano:
name: Luis Majano
title: CEO, Ortus Solutions
bio: >
Founder of Ortus Solutions and creator of ColdBox, WireBox, and BoxLang.
url: https://github.com/lmajano
email: [email protected]
socials:
github: https://github.com/lmajano
twitter: https://x.com/lmajano
Only name is required. An author gets their own /blog/authors/<id>/
page only once at least one post credits them. Avatar by convention -
docs/assets/blog/authors/<id>.{jpg,jpeg,png,webp,svg} is picked up
automatically; an explicit avatar key always overrides it.
Nav entry
A "Blog" nav entry is added automatically once there's at least one
non-draft post (appended last by default). To place it explicitly (and
suppress the auto entry), add your own nav/docs/nav.json entry with a
bare url (bypasses the usual path-must-be-a-real-page rule):
nav:
- path: index.md
- title: Blog
url: blog/index.html
icon: lucide:newspaper
- path: about.md
Feed
/blog/feed.xml (RSS 2.0) - written when baseURL is a full URL (same
requirement as sitemap.xml) and blog.feed isn't false. Each category
also gets /blog/category/<slug>/feed.xml. Both capped to blog.feedLimit
(default 25; 0 = uncapped). Config lives in bxsites.yaml's blog key
- see
bx-sites-configuration.
Restyling the blog
Blog pages render through the exact same layout.bxm/page.bxm as every
other page - no separate blog theme (see bx-sites-themes). The generated
markup (post cards, meta line, pager, author profile, browse-by-year/
category links) uses fixed CSS classes (blog-post-card, blog-post-meta,
blog-post-featured-image, blog-draft-badge, blog-pager,
blog-author-profile, blog-archive-links/blog-category-links) -
restyle them via extraCss, or change structure via a theme override. The
post-card/pager/author-profile markup itself is generated by BlogBuilder.bx,
not read from a theme template - it can be restyled with CSS but not
replaced with a custom template.
Versioning
Add a docs/versions/ folder; each direct subfolder inside is built as its
own fully self-contained doc tree alongside the regular docs/ (always
built as "Latest"):
docs/
βββ index.md
βββ guides/
βββ versions/
βββ 1.0/
β βββ index.md
β βββ guides/
βββ 2.0/
Each version folder is a normal docs/-shaped tree - own index.md, own
nav, own pages - built to site/versions/<name>/, sharing the project's
single bxsites.yaml config/theme.
Cutting a version:
bxSites version:new --name=1.0
Snapshots the current docs/ tree (excludes assets/, versions/,
i18n/, blog/ - each is its own separately-loaded tree). Typical
workflow: finish docs for a release, cut the version right before starting
the next release's docs. There's no "un-cut" verb; editing an already-cut
version's pages is just editing the file directly - page:new/
page:rename/post:new always target the main docs/ tree only.
Version names sort newest-first, numerically (2.0 before 10.0);
every theme renders a version-switcher automatically once more than one
version exists. sitemap.xml/llms.txt include every version.
Out of scope: search is scoped per-tree (separate search-index.json
per version) except with the pagefind provider, which indexes the whole
built site (see bx-sites-search); no deprecated/EOL flag or custom label -
a version's switcher label is always its folder name.
i18n
Translated content lives in docs/i18n/<code>/, mirroring docs/
page-for-page:
docs/
βββ index.md
βββ guides/setup.md
βββ i18n/
βββ es/
β βββ index.md
β βββ guides/setup.md
βββ ar/
βββ index.md
<code> is both the folder name and the built URL prefix
(/es/guides/setup/) - letters/digits/hyphens only (es, pt-BR,
zh-Hans). The regular docs/ tree is always the default locale, built
unprefixed at the root.
Scaffolding a locale:
bxSites i18n:new --code=es
Seeds index.md from the default locale's own. Then register the
label/direction in bxsites.yaml:
i18n:
defaultLocale: { code: en, label: English }
locales:
- { code: es, label: EspaΓ±ol }
- { code: ar, label: Ψ§ΩΨΉΨ±Ψ¨ΩΨ©, dir: rtl }
- { code: pt-BR, label: "PortuguΓͺs (Brasil)", flag: π§π· }
defaultLocale only needs setting if the default isn't English. A folder
with no matching locales entry still builds (using its bare code as
label) - locales supplies display metadata, not the on/off switch.
bxSites i18n:status reports per-locale translation coverage (which pages
exist at the same relative path for each locale) - always exits 0,
purely informational.
Untranslated pages - a page missing from docs/i18n/es/ still builds at
its expected URL, showing the default locale's content with a "not
translated yet" notice. Nothing 404s. Every locale's nav is always the exact
same shape as the default's.
Language switcher - appears automatically once more than one locale exists, no opt-in.
Composing with versions: docs/versions/<name>/i18n/<code>/ mirrors
that version's own structure. Switching version always drops to that
version's own default locale; switching locale always stays on the current
version.
Theme chrome (UI strings) - the surrounding UI text (search placeholder,
"On this page," 404 page, etc.) translates per locale too. de/es/it/
ja ship a built-in translation; any other code falls back to English.
Override specific keys per locale:
i18n:
locales:
- code: fr
label: FranΓ§ais
strings:
searchPlaceholder: Rechercher dans la documentation...
onThisPage: Sur cette page
Overridable keys: searchPlaceholder, searchAriaLabel, searchNoResults,
toggleDarkMode, toggleNavigation, toggleSection ({title}),
repository, version, language ({label}), onThisPage,
editThisPage, downloadMarkdown, lastUpdated, notTranslatedNotice
({locale}), notFoundTitle, notFoundBody, tagsTitle, pageNavigation.
Out of scope: blog chrome stays in English; lastUpdated's date value
isn't locale-formatted; RTL is baseline (mirrors layout, a few decorative
details like an admonition's accent-bar side don't flip); no machine
translation - every locale file is hand-authored. custom: icons and
::: include both resolve against the project's shared docs/assets/
regardless of locale.
Redirects
Keep an old URL working after a page moves/renames - a static HTML stub redirects a stale bookmark/search result to the new URL.
Per-page - frontmatter redirect_from on the page that replaced the old
URL:
---
title: New Setup
redirect_from:
- guides/old-setup
- setup
---
Each entry is a pretty-URL segment (no leading/trailing slash, no extension). Scoped to whichever tree the page belongs to (a version's page redirects within that version, a locale's within that locale).
Site-wide - bxsites.yaml's redirects array, for an old URL that
never belonged to one specific page:
redirects:
- from: old-guide
to: guides/new-guide/
- from: moved-to-another-site
to: https://example.com/docs
to is a root-relative path (resolved against baseURL) or a full
https:// URL. Only applies to the main site tree.
bxSites page:rename --from=... --to=... (see bx-sites-getting-started)
stamps redirect_from automatically on top of rewriting every relative
Markdown link that pointed at the old path - always prefer it over
hand-moving a file.
Conflicts fail the build rather than silently overwriting: a redirect's
from colliding with a real page, or two redirects targeting the same
from.
Out of scope: blog posts don't get redirect_from (use a redirects
config entry instead); no wildcard/pattern redirects - every from is one
exact path.