πŸ”· Core

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.

$ npx skills add ortus-boxlang/bx-sites-skills/skills/bx-sites-blog-versioning-i18n
$ coldbox ai skills install ortus-boxlang/bx-sites-skills/skills/bx-sites-blog-versioning-i18n
πŸ”— https://skills.boxlang.io/skills/raw/ortus-boxlang/bx-sites-skills/skills~bx-sites-blog-versioning-i18n

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 matching docs/blog/authors.yml, or a plain unlinked name
  • categories - taxonomy, each gets its own /blog/category/<slug>/ page + feed. Unrelated to tags.
  • 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 becomes og:image/Twitter card unless ogImage overrides it; gets the same responsive <picture>/WebP treatment as any image (see bx-sites-markdown)
  • slug - overrides /blog/<slug>/ (defaults from filename)
  • draft: true - excluded from a real build entirely; serve previews 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 - see bx-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.