Byline CMS
  • Home
  • Docs
  • About
Byline CMS
View on GitHub
  • Getting Started
    • Overview
    • CLI
    • Development environment and example application
    • Configuration
    • Upgrading from 3.21 to 4.x
    • Upgrading from 4.11 to 4.12
    • Upgrading from 4.19 to 5.x
    • PostgreSQL v5 Cutover
    • MySQL v5 Cutover
    • Upgrading from 5.2 to 6.x
  • Why Byline
    • Overview
    • Mission & Vision
    • Content Management in the Time of AI
    • Byline for Collections
  • Key Architectural Decisions
    • Overview
    • Core Document Storage
    • Core Composition
    • Transactions
    • Path Grammar
    • Deployment Topologies
  • Collections
    • Overview
    • Fields
    • Blocks
    • Relationships
    • Document Trees
    • Document Paths
    • File / Media Uploads
    • Rich Text Editor
    • Collection Versioning
    • Singletons
  • Reading & Delivery
    • Overview
    • Client SDK (@byline/client)
    • Routing & API
    • Transports
    • Markdown Export
    • MCP Server
    • Caching
  • Search
    • Overview
    • Configure search
    • Indexing and reindexing
    • Search API
    • Search provider contract
    • Portable multilingual search analysis
    • PostgreSQL and MySQL search providers
    • Attachment extraction for search
    • Native search engines and backend portability
    • Semantic discovery and institutional standards
  • Auth & Security
    • Overview
    • Authentication & Authorization
    • Auditability
  • Internationalization (i18n)
    • Overview
    • The host i18n system
    • Admin interface translations
    • Content locales
    • Administering content locales
  • Admin UI
    • Overview
    • UI Kit (@byline/ui)
    • Admin-config registration
    • Collection groups
    • Richtext field capabilities
  • API Reference
    • Overview
    • Configuration API
    • Collections API
    • Fields API
    • Client SDK API
  • Scheduling
    • Overview
    • Recurring tasks
    • Scheduled publication
  • Analytics
    • Overview
    • Analytics configuration
    • Analytics browser agent and consent
    • Analytics ingest and deployment
    • Analytics storage, rollups, and operations
  • Testing
  • Home

Richtext field capabilities

Companions:

  • Fields — how a richText field is declared on the schema side and configured on the admin side.
  • Admin-config registration — how the admin and editor configuration reaches the browser, and why it is code-split away from public routes.
  • Admin interface i18n — the translation system the editor's notices use.

A richtext field in Byline is a Lexical editor configured by a list of extensions. Read this document when you are narrowing a field — a title that should hold no headings, a caption that should hold no tables — or when you need to know what will happen to documents already written under a wider configuration.

Two decisions look similar and are not. Capabilities are the structures a field accepts: whether a heading can exist in it at all, however it arrives. Controls are the buttons and menus the editing surface shows. Removing a control hides an affordance; it does not stop content arriving by paste, by Markdown, or from storage.

Overview

Three rules anchor the model.

  1. editorConfig.extensions controls optional features. It is the list a site adds to, removes from, and reorders.
  2. The resolved editor's registered node types decide what the field accepts. That is the configured extensions, plus everything they pull in transitively, plus mandatory infrastructure. It is not the same as the configuration list: table and list nodes arrive through @lexical/table and @lexical/list as dependencies of Byline's wrappers, and never appear in the list themselves.
  3. settings.controls decides only what the interface shows. No flag there restricts content.

Each extension owns the node classes for its feature, so removing an extension removes its controls, its behaviour and its node registration together. A node class that is not registered cannot be created by a command, cannot be produced by a Markdown shortcut, and cannot survive a paste.

import { builtInExtensions, lexicalEditor } from '@byline/richtext-lexical/config'
// A title field that accepts inline formatting and nothing structural.
export const TitleEditor = lexicalEditor((c) => {
c.extensions
.remove(builtInExtensions.Heading)
.remove(builtInExtensions.Quote)
.remove(builtInExtensions.List)
.remove(builtInExtensions.CheckList)
.remove(builtInExtensions.Table)
return c
})

Paste a heading into a field configured that way and it arrives as a paragraph with its text intact.

What capabilities cannot constrain

Bold, italic and inline code are TextNode formats rather than node types. They have no node class to unregister, so they still arrive by paste no matter how a field is configured. controls.inlineCode hides the button and nothing more.

Line breaks and paragraph structure are likewise governed by Lexical's own import rules rather than by node registration. Constraining them needs the DOMImportExtension rules pipeline, which Byline does not currently wire up.


Quick reference

Stop a field accepting a structure

Remove the extension that owns it, through a client-side wrapper registered on FieldAdminConfig.editor.

Edit: your field's admin config, for example apps/webapp/byline/fields/richtext/lexical-richtext-minimal-admin.tsx.

Extension references are not JSON-safe, so they cannot be baked into a schema. A schema-side editorConfig carries settings only.

Hide a control without disabling the feature

Set the flag on settings.controls. The field still accepts the structure; the button is gone.

Edit: your schema-side field helper, for example lexicalRichTextCompact().

config.settings.controls.undoRedo = false // keyboard undo still works

Find out what existing documents will do

Generate a capability manifest, then scan stored documents against it. See Checking existing content.


Extensions and the nodes they own

Extension

Node types

Heading

heading

Quote

quote

List, CheckList

list, listitem

Table

table, tablerow, tablecell

CodeHighlight

code, code-highlight

Link, AutoLink

link, autolink

HorizontalRule

horizontalrule

Admonition

admonition

Layout

layout-container, layout-item

InlineImage

inline-image

YouTube, Vimeo

youtube, vimeo

CoreNodesExtension owns mark and overflow. It is injected by the editor root rather than carried in the configurable list, because neither class belongs to a feature a field can switch off. It is deliberately absent from builtInExtensions and cannot be removed.

Removing CheckList alone leaves list and listitem registered, because ListExtension owns those node classes. The check-list control disappears; bulleted and numbered lists remain.

Stored content a field no longer supports

Narrowing a field does not rewrite anything already stored. Lexical throws when asked to load a node type an editor has not registered, so without intervention a field whose configuration narrowed would open blank and erroring on an older document.

Byline adapts such content when the editor loads it. Three properties govern this:

  • Only unsupported nodes are converted. A supported node keeps its own properties untouched while its children are still inspected, because a supported parent may hold an unsupported descendant.
  • Nothing is guessed at. A node type with no declared conversion — an image, an embed, or a custom node from your own extension — is never restructured.
  • Adaptation is presentational. Stored versions are never rewritten. @byline/client reads, Markdown export and every other consumer see the document exactly as saved. The adapted form reaches storage only if an editor saves an edit to that field.

Declared conversions

Structure

Becomes

Preserved

Heading, quote, code block, list item

one paragraph each

text and inline formats

Table, list, layout

one paragraph per cell, item or column

text

Admonition

paragraphs

body text, with the title as the first line

Link, auto-link

inline text

link text, with the URL appended

Highlighted code token

plain text

text and inline formats

Horizontal rule

removed

neighbouring blocks

Adjacent blocks stay separate, and an empty block stays an empty line. When conversion removes everything, the field receives an empty paragraph, because Lexical rejects an empty document root.

The editor shows an inline notice when content was adapted, and the field stays editable:

This content contains formatting this editor no longer supports. Saving edits will use the supported formatting.

Content with no conversion

An inline image carries a relation to a media document and a caption held in a nested editor; an embed carries its video identity. No structural rewrite preserves either, so Byline does not attempt one. The field opens read-only, names the content, and directs the reader to an administrator. Nothing is discarded.

A node type Byline does not recognise takes the same path. That includes a node contributed by your own extension: it needs no registration with Byline to be handled safely, and the notice names it by its type id.

Checking existing content

Before narrowing a field on a live installation, check what the change will do to documents already written. The check runs in two steps, because capabilities and content are established differently: measuring what a field accepts means building its editor, which needs a DOM and a React-capable module graph, while scanning stored values needs a database connection. A manifest file travels between them.

1. Generate the manifest.

cd apps/webapp && pnpm byline:richtext-manifest

This mounts every richtext field's editor in jsdom, records the node types each one registered, and writes byline/generated/richtext-capabilities.json. It needs no browser and no running application, and it is what CI runs against this repository's own configuration.

The file is a per-installation artifact, specific to your field configuration and stale as soon as a field is reconfigured. It is not committed, and byline/generated/ holds no example to compare against: generate one when you need it.

This command is monorepo-only for now

The harness resolves @byline/* through workspace links, which is why Vite processes the whole module graph including its CSS. An application installing @byline/* from npm cannot run it: Vitest externalises those packages, Node performs the import, and the run fails with ERR_UNKNOWN_FILE_EXTENSION on @byline/ui's published CSS. Tracked in issue #96 .

On a downstream installation, produce the manifest from the interactive page described below, then scan with the published APIs. Note that the page is reference-application source as well: copy src/routes/_byline/admin/richtext-capabilities.{tsx,css} and src/lib/richtext-capability-{probe.tsx,targets.ts} into your application first — every package they import is published, so they work once copied. byline/scripts/richtext-scan.ts is likewise reference-application source and is not installed by byline init.

There is also an interactive equivalent at /admin/richtext-capabilities, which shows each field, the editor it resolved to, and the resulting manifest to copy or download. It is useful for seeing why a field measured as it did. It is served only in development, so it is not the path to use when checking a production installation.

Neither path — the command or the interactive page — reads or writes any content.

2. Scan stored values.

cd apps/webapp && pnpm tsx byline/scripts/richtext-scan.ts
The scan reads content but is not free of side effects

The script imports byline/server.config.ts to resolve the collection registry and a database connection, and that module applies pending search and analytics migrations as a deliberate startup step. The scan reads every stored value and writes none, but running it applies any migration the configured providers have outstanding.

This matters on a 6.0 upgrade in particular: @byline/search-postgres ships a repair migration that empties the search index on an affected installation, which then needs a rebuild before search returns results. Run the scan against a staging copy, or check first with the pre-flight query in Upgrading from 5.2 to 6.x.

The script reads every value in byline_store_json — every document, every version including archived ones, every locale — matches each against the manifest, and reports per collection:

pages
content.photoBlock.caption [fr] doc 019f70… version 019f70…
adapts: heading
content.richTextBlock.richText [en] doc 019f70… version 019f70…
read-only: inline-image
Will adapt on open: 6
Will open read-only: 44
Capabilities unknown: 0

It exits non-zero when any value would make a field read-only, or when any field's capabilities could not be measured, so it can gate a deployment.

A field whose capabilities are unknown is reported as capabilities unknown — not examined, never counted as clean. Reporting a document safe when it was never looked at is the one failure an upgrade check must not have. The script also lists stored field paths with no manifest entry: those are ordinary json fields, or the manifest is stale and needs regenerating.

Stored values are addressed by instance path — content.1.photoBlock.caption, carrying the item index — while the manifest is keyed by declaration path. The script elides the selectors to match them, so a field nested in a block, or in an array inside a block, is covered without extra configuration. See Path grammar for the two notations.

Using the APIs directly

Both halves are exported for hosts that need their own workflow. @byline/richtext-lexical/scan is runtime-free — no React, no Lexical, no CSS — so a Node script can import it; measuring capabilities lives on the root entry and needs a DOM.

import { buildManifest, capabilitiesFor } from '@byline/richtext-lexical'
import { scanDocument, summarise } from '@byline/richtext-lexical/scan'
// In a browser or jsdom: measure what each field accepts.
const manifest = buildManifest([capabilitiesFor('publications', 'title', editorConfig)])
// In Node, against stored values: report what each will do.
const finding = scanDocument(storedValue, manifest.fields[0], {
documentId: doc.id,
versionId: version.id,
})

scanDocument returns undefined when a value needs no adaptation. Otherwise it reports adaptedTypes, refusedTypes, or unmapped: true when the field's capabilities were never measured. summarise() splits a list of findings into those three groups.

Capabilities are measured by building the editor rather than by inspecting the configuration list, so the report cannot disagree with the runtime it predicts.

Upgrading from 5.x

The 6.0 release replaced EditorSettings.options with no alias and made extension removal decide what a field accepts. Upgrading from 5.2 to 6.x carries the registration mapping table, the before-and-after preset, and the order to run the pre-flight scan in.

Not yet shipped

  • Format-level restriction. Bold, italic and inline code cannot be constrained, as described above. Doing so requires the DOMImportExtension rules pipeline, which Byline does not wire up.
  • Single-line fields. Removing structural extensions does not by itself produce a single-line editor. Paragraph handling, Enter behaviour and paste shaping all need explicit constraints.
  • Exhaustive refusal reporting. When a document is refused, the notice names the outermost unsupported type. A custom node wrapping an embed reports the custom node alone.
PreviousCollection groups
NextAPI Reference

On this Page

  • Overview
  • What capabilities cannot constrain
  • Quick reference
  • Stop a field accepting a structure
  • Hide a control without disabling the feature
  • Find out what existing documents will do
  • Extensions and the nodes they own
  • Stored content a field no longer supports
  • Declared conversions
  • Content with no conversion
  • Checking existing content
  • Using the APIs directly
  • Upgrading from 5.x
  • Not yet shipped
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.