bx-sites-blog-versioning-i18n
Set up and write a blog, versioned docs, translated (i18n) locales, and numbered courses 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, bxsites.yaml's versions.default (publish a version at the site root, docs/ at /next/), docs/i18n/<code> and i18n:new, composing versions with locales, theme-chrome translation strings, redirects (frontmatter redirect_from and bxsites.yaml's redirects), and docs/data/courses.yaml with per-lesson progress tracking. Use this whenever a user wants to add a blog post, cut a new docs version, add a translated locale, keep an old URL working after a page moves, or turn a set of pages into a guided lesson-by-lesson course. For the ::: course ::: index block itself, see bx-sites-content-blocks.
BxSites Blog, Versioning, i18n, Courses & Redirects
All of these are convention over configuration - no bxsites.yaml key
turns them on, just a folder (or a data file, for courses). The only config
involved is tuning: blog.*, i18n.*, and versions.default.
Blog
Drop posts under docs/blog/posts/ and BxSites builds /blog/ (paginated),
a category page per category, a year-archive page per calendar year
(/blog/archive/<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. Lists paginate via blog.postsPerPage (default 10);
later pages live at .../page/2/. Each post shows a reading time of
words / 200 (minimum 1 minute).
Scaffolding a post
bxSites post:new --title="My New Post" [--slug=] [--date=] [--authors=] [--categories=] [--tags=] [--draft]
--draft defaults true; pass --!draft (or --draft=false) 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>. A missing or invalid date fails the build withBxSites.InvalidBlogPost; two posts resolving to the same slug fail withBxSites.DuplicateBlogSlug(set an explicitslugon one).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 layout- renders this post's own body through a named.bxminstead of the theme's default (the active theme's ownblog-page.bxmwhen it has one, elsepage.bxm) - seebx-sites-themes's "Multiple layouts per page"
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. authors.toml and authors.json also work (first found in
the order .yml, .toml, .json wins; .yaml is not recognized). A file
that doesn't parse fails with BxSites.InvalidBlogAuthors:
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). The url
must be exactly blog/index.html or the auto entry is still added. The auto
entry only ever goes in the main tree's nav:
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.
Finding posts and drafts
build always skips drafts, so two read-only verbs surface them:
bxSites blog:drafts
bxSites blog:find [--author=] [--category=] [--tag=] [--since=] [--until=] [--drafts]
blog:find filters are all optional (author/category/tag are
case-insensitive exact matches; --drafts includes draft posts).
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 (10.0 before 9.0 before
2.0);
every theme renders a version-switcher automatically once more than one
version exists. sitemap.xml/llms.txt include every version.
Publishing a version at the site root
One config key changes which tree owns the root - bxsites.yaml's
versions.default (see bx-sites-configuration):
versions:
default: "1.0.x"
Names a docs/versions/<name>/ folder to build at the site root (/)
instead of /versions/<name>/ - never both. The plain docs/ tree then
builds separately at /next/: still fully browsable and linkable, with
its own search index and tags page. The switcher shows 1.0.x (Current)
selected at the root, then every other released version, then a divider
and a Next entry pointing at /next/ last - released versions first,
the work-in-progress tree set apart at the bottom rather than squeezed in
second. /next/ pages are excluded from sitemap.xml, and robots.txt
gains a Disallow: /next/ line.
Each switcher entry carries a kind ("current"/"released"/"next")
driving that grouping/labeling - renderVersionSwitcher( variables.versions, variables.currentVersion )
(bound into every theme's render scope, built-in or custom) builds the
whole thing; see bx-sites-themes.
A value that doesn't match any discovered version folder fails the build
with BxSites.UnknownDefaultVersion - it never falls back silently. Patch
releases are just in-place edits to the version folder (no new cut, no
config change); only a minor/major bump warrants version:new plus moving
versions.default.
Limitation (current behavior): /next/ gets no locale sub-trees, and
with versions.default set the root locale sub-trees are built from
docs/versions/<default>/i18n/<code>/, not docs/i18n/<code>/ (which is not
built at all then). /next/'s language switcher is still populated from
docs/i18n/*, so it can link to /next/<code>/ pages that are never
generated. Keep translations under the default version's own i18n/ folder.
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. flag
is an optional emoji override - most common codes already resolve to a
sensible flag on their own (region code first, pt-BR, then base language,
pt), falling back to a plain π.
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. The reverse is silently dropped: a translated
page with no counterpart in the default tree is never built (a locale's
output is exactly the default tree's pages, matched case-insensitively by
relative path).
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 (ja has no versionCurrent, and no built-in
locale translates the page-actions strings); 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 (every key in StringsResolver's defaults): searchPlaceholder,
searchAriaLabel, searchNoResults, toggleDarkMode, toggleNavigation,
toggleSection ({title}), repository, version, versionCurrent,
language ({label}), onThisPage, editThisPage, downloadMarkdown,
lastUpdated, notTranslatedNotice ({locale}), notFoundTitle,
notFoundBody, tagsTitle, pageNavigation, pagePrevious, pageNext,
pageActionsButton, pageActionsAriaLabel, plus the page-actions menu
labels, each with a ...Hint twin for its tooltip text: copyPageMarkdown,
viewAsMarkdown, copyPageLink, openInChatGPT, openInClaude,
editOnGitHub, exportAsPdf, reportIssue, shareOnX, shareOnLinkedIn,
shareOnFacebook.
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.
Courses
A course turns a set of pages into a guided, numbered sequence - lesson 1, lesson 2, ... - with its own auto-generated index, its own scoped "Lesson N of M" prev/next (independent of the site's global page-to-page order), and per-browser progress tracking once a reader opens a lesson.
The manifest
Add docs/data/courses.yaml (.toml/.json also work - see
bx-sites-variables-functions's Data Files section). Each top-level key is
one course; its lessons array lists that course's pages in order - array
position is the lesson number:
getting-started:
title: "Getting Started with BoxLang"
description: "A guided walkthrough from install to your first deployed site." # optional
lessons:
- guides/course/introduction.md
- guides/course/windows-installation.md
- guides/course/mac-installation.md
Each lessons entry is a docs/-relative path, same convention as
nav.json. A lesson's title/summary come from that page's own
frontmatter - never duplicated into the manifest, so renaming a page or
editing its summary is reflected in the course index automatically.
Multiple courses just means multiple top-level keys in the same file. A
finalTest key on a course is reserved for later and currently ignored.
The index and lesson pages
::: course id="getting-started" ::: (see bx-sites-content-blocks)
renders that course's numbered index as one real <ol> - a typo'd id, or
a course whose lessons don't all exist, degrades to a visible note rather
than failing the build.
Every page listed in a course's lessons automatically gets a course
context (page.course - see Context variables in
bx-sites-variables-functions) with its own
position, title, and scoped prev/next (page.course.prevLesson/
.nextLesson), plus page.course.indexUrl (the page that holds the
course's ::: course block) - unlike the site's global page.prevPage/.nextPage, which
walks the whole nav tree regardless of any course. A lesson doesn't declare
which course it's in - the manifest is the one source of truth, and the
same lesson path listed under two different courses is a build error. The
bootstrap theme renders "Lesson N of M"/course-scoped pager/"Mark complete"
natively on the lesson page; every other built-in theme computes
page.course correctly for a project to surface via its own theme
override, native chrome there being on the roadmap.
Progress tracking
Purely client-side, layered on a course that already fully works without
it (the index and scoped pager both render server-side). Once a reader
opens a lesson, course-progress.js (shared across every theme, always
included, no opt-in) records the visit in that browser's own
localStorage, under bxsites-course-progress-<courseId> - firstStarted,
lastVisited, and a completed map keyed by URL. A lesson is marked
complete automatically on visit; a "Mark complete"/"Mark incomplete" toggle
lets a reader undo or redo that. No account/backend - it doesn't sync
across devices, and storage being unavailable (private browsing, blocked
site data) degrades silently to "no progress remembered."
Scope
A course whose lessons don't all exist as real pages in the tree
currently being built is silently skipped for that tree (not a build
failure) - relevant because docs/data/courses.yaml is loaded once,
project-wide, and reused unchanged across every version/locale tree; a bare
docs/versions/<name>/ snapshot may not contain a course's lesson files at
all. A lesson can only belong to one course. No nested/multi-track courses,
no per-version course manifests, in this first version.
BxSites.InvalidConfig throws when docs/data/courses.yaml has a shape
problem: a course value isn't an object, is missing a title, its
lessons isn't a non-empty array of path strings, or the same lesson path
is listed under more than one course.
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: the /next/ tree (see
Versioning) gets page-level redirect_from only, never these.
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. Details: links are rewritten across the whole content
root, including versions/** and i18n/**; the moved file's own relative
links are re-based to its new folder; --from/--to must end in .md and
accept backslashes and a leading slash; repeated renames of the same page
append to its redirect_from rather than replacing it.
Conflicts fail the build rather than silently overwriting
(BxSites.RedirectConflict): a redirect's from colliding with a real page,
two redirects targeting the same from, or a from whose output path
already exists on disk (for example one that lands inside another tree such
as a locale prefix). An empty from, or one that resolves outside the output
directory, fails with BxSites.InvalidRedirect.
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.