Byline CMS
  • Home
  • Docs
  • About
Byline CMS
View on GitHub
  • Getting Started
    • Experimental CLI
    • Development environment and example application
  • Why Byline
    • Mission & Vision
    • Content Management in the Time of AI
  • Key Architectural Decisions
    • Core Document Storage
    • Core Composition
    • Transactions
  • Collections
    • Fields API
    • Relationships
    • Document Trees
    • Document Paths
    • File / Media Uploads
    • Rich Text Editor
    • Collection Versioning
  • Reading & Delivery
    • Client SDK (@byline/client)
    • Routing & API
    • Transports
    • Markdown Export
    • MCP Server
    • Caching
  • Auth & Security
    • Authentication & Authorization
    • Auditability
  • Internationalization (i18n)
    • The host i18n system
    • Admin interface translations
    • Content locales
    • Administering content locales
  • Admin UI
    • UI Kit (@byline/ui)
    • Client-config registration
  • Testing
  • Home

Administering content locales

Switching the default content locale

A system's default content locale (i18n.content.defaultLocale) does two different jobs:

  1. A config preference — which locale new content is authored in, and which locale is served for a request that doesn't specify one. Genuinely global, and genuinely should be switchable.
  2. A per-document data anchor — every document's content rows, its path row, and its completeness ledger were originally written keyed to whatever the default was at write time.

Job (2) is the trap. If the default were only a global config value, flipping en → fr on a live system would silently re-interpret every existing document against an anchor it was never written for: en-authored fields would read empty (the fallback floor moves to fr, which is empty), findByPath(slug, 'fr') would 404 (path rows live under en), and the completeness yardstick would become meaningless. Non-localized content (the 'all' sentinel) and explicit 'en' reads are unaffected — but everything anchored to the default breaks.

The fix: a per-document source_locale

Byline records a per-document source_locale on byline_documents, set once at creation to the locale the first version was authored in (defaulting to the global config default at that moment). It re-bases each anchor — the fallback floor, the path locale, the completeness yardstick — from "the global config" to "this document's own truth." With that column in place, switching i18n.content.defaultLocale is a non-event for existing data: every document rides its own source_locale, and the global default is demoted to its honest role — the authoring default for new documents plus the request-time fallback when a read specifies no locale.

source_locale is surfaced on every read payload as sourceLocale, and the editor shows it as a small neutral badge next to the document title. For in-place upgrades, the column is populated at boot — initBylineCore() stamps any unstamped rows with the configured default idempotently — so a vanilla drizzle:migrate never fails on a constraint and upgrades self-heal.

Re-anchoring documents onto the new default

Switching the config is safe immediately, but the harder part of actually moving documents onto the new default is having them fully translated into it — the system can never manufacture a primary language with holes. So the realistic workflow is: flip the config → translate documents into the new locale over time → re-run the bulk re-anchor to sweep up the now-complete ones.

The bulk re-anchor is a script (apps/webapp/byline/scripts/re-anchor.ts):

pnpm tsx byline/scripts/re-anchor.ts --to fr [--collection <path>] [--dry-run]

Per document, in one transaction, it: skips documents that are not-found, already-anchored, or incomplete in the target locale (eligibility comes from the completeness ledger — it refuses to manufacture a translation); otherwise flips source_locale, moves the path row onto the target locale (re-tagging the slug, keeping the URL stable), and writes a new immutable version recomputing its ledger against the new anchor. Each document is its own transaction, so the operation is idempotent and resumable — and --dry-run reports the would-be outcome plus the backlog. The skipped-incomplete report is your outstanding-translation list.

docsPreviousContent locales
docsNextAdmin UI
Byline CMS

Building the future of content management, one commit at a time.

Project

  • Documentation
  • Roadmap
  • Contributing
  • Releases

Community

  • GitHub Discussions
  • Blog
  • Newsletter

Legal

  • Privacy Policy
  • Terms of Use
  • Cookies

© 2026 Infonomic Company Limited and contributors. Open source and built with ❤️ by the community.