Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rich Text

Rich text field with a ProseMirror-based WYSIWYG editor. Stored as HTML (default) or ProseMirror JSON.

SQLite Storage

TEXT column containing HTML content (default) or ProseMirror JSON document.

Definition

crap.fields.richtext({
    name = "content",
    admin = {
        placeholder = "Write your content...",
    },
})

Storage Format

By default, richtext fields store raw HTML. Set admin.format = "json" to store the ProseMirror document structure as JSON instead:

crap.fields.richtext({
    name = "content",
    admin = {
        format = "json",
    },
})

HTML vs JSON

HTML (default)JSON
StorageRaw HTML stringProseMirror doc.toJSON()
Round-trip fidelityLoses some structural infoLossless
Programmatic manipulationParse HTMLWalk JSON tree
FTS searchIndexed as-isPlain text extracted automatically
API responseHTML stringJSON string

Important notes

  • Changing format does NOT migrate existing data. If you switch from "html" to "json" (or vice versa), existing documents retain their original format. The editor will attempt to parse the stored content according to the current format setting.
  • The API returns the stored format as-is (HTML string or JSON string).
  • Full-text search automatically extracts plain text from JSON-format richtext fields.

Toolbar Configuration

By default, all toolbar features are enabled. Use admin.features to limit which features are available:

crap.fields.richtext({
    name = "content",
    admin = {
        features = { "bold", "italic", "heading", "link", "bulletList" },
    },
})

Available Features

FeatureDescription
boldBold text (Ctrl+B)
italicItalic text (Ctrl+I)
codeInline code (Ctrl+`)
linkHyperlinks
headingH1, H2, H3 headings
blockquoteBlock quotes
orderedListNumbered lists
bulletListBullet lists
codeBlockCode blocks (```)
horizontalRuleHorizontal rule

When features is omitted or empty, all features are enabled (backward compatible). Undo/redo buttons are always available regardless of feature configuration.

Custom Nodes

Custom ProseMirror nodes let you embed structured components (CTAs, embeds, alerts, mentions, etc.) inside richtext content. Register nodes in init.lua, then enable them on specific fields via admin.nodes.

Registration

Node attributes use the same crap.fields.* factory functions as collection fields. Only scalar types are allowed: text, number, textarea, select, radio, checkbox, date, email, json, code.

-- init.lua
crap.richtext.register_node("cta", {
    label = "Call to Action",
    inline = false, -- block-level node
    attrs = {
        crap.fields.text({ name = "text", required = true, admin = { label = "Button Text" } }),
        crap.fields.text({ name = "url", required = true, admin = { label = "URL", placeholder = "https://..." } }),
        crap.fields.select({ name = "style", admin = { label = "Style" }, options = {
            { label = "Primary", value = "primary" },
            { label = "Secondary", value = "secondary" },
        }}),
    },
    searchable_attrs = { "text" },
    render = function(attrs)
        return string.format(
            '<a href="%s" class="btn btn--%s">%s</a>',
            attrs.url, attrs.style or "primary", attrs.text
        )
    end,
})

Field configuration

crap.fields.richtext({
    name = "content",
    admin = {
        format = "json",
        nodes = { "cta" },
        features = { "bold", "italic", "heading", "link", "bulletList" },
    },
})

Node spec options

OptionTypeDescription
labelstringDisplay label (defaults to node name)
inlinebooleanInline vs block-level (default: false)
attrstable[]Attribute definitions (see below)
searchable_attrsstring[]Attr names included in FTS search index
renderfunctionServer-side render function: (attrs) -> html

Allowed attribute types

Node attrs support all scalar field types. Complex types (array, group, blocks, relationship, upload, richtext, row, collapsible, tabs, join) are rejected at registration time.

TypeAdmin Input
textText input
numberNumber input
textareaMulti-line textarea
selectDropdown with options
radioRadio button group
checkboxCheckbox
dateDate picker
emailEmail input
jsonMonospace textarea
codeMonospace textarea

Supported attribute features

Node attrs support most field features that make sense in the richtext context.

Admin display hints

These control how attributes appear in the node edit modal:

FeatureEffect
admin.hiddenAttribute is not rendered in the modal (value preserved)
admin.readonlyInput is read-only / disabled
admin.widthCSS width on the field container (e.g. "50%")
admin.stepstep attribute on number inputs (e.g. "0.01")
admin.rowsNumber of rows for textarea/code/json fields
admin.languageLanguage label suffix for code fields (e.g. "JSON")
admin.placeholderPlaceholder text on inputs
admin.descriptionHelp text below the input
min / maxMin/max on number inputs
min_length / max_lengthMinlength/maxlength on text/textarea inputs
min_date / max_dateMin/max on date inputs
picker_appearanceDate input type: "dayOnly" (default), "dayAndTime", "timeOnly", "monthOnly"

Server-side validation

Node attribute values inside richtext content are validated server-side on create/update. The following checks run automatically:

CheckDescription
requiredAttribute must have a non-empty value
validateCustom Lua validation function
min_length / max_lengthText length bounds
min / maxNumeric bounds
min_date / max_dateDate bounds
email formatValid email for email type attrs
option validityValue must be in options for select/radio

Validation errors reference the node location: "content[cta#0].url" (first CTA node’s url attribute in the content field).

before_validate hooks

Node attrs support hooks.before_validate for normalizing values before validation:

crap.richtext.register_node("cta", {
    label = "CTA",
    attrs = {
        crap.fields.text({
            name = "url",
            required = true,
            hooks = {
                before_validate = { "hooks.trim_whitespace" },
            },
        }),
    },
})

The hook receives (value, context) and returns the transformed value. Runs before validation checks.

Unsupported features

These features have no effect on node attributes and will produce a warning at registration time:

FeatureReason
hooks.before_changeNo per-attr write lifecycle
hooks.after_changeNo per-attr write lifecycle
hooks.after_readNo per-attr read lifecycle
access (read/create/update)No per-attr access control
uniqueNo DB column
indexNo DB column
localizedRichtext field itself is localized or not
mcp.descriptionNot exposed as MCP fields
has_manyDoesn’t apply to scalar node attrs
admin.conditionNot yet supported (deferred)

Server-side rendering

Use crap.richtext.render(content) in hooks to replace custom nodes with rendered HTML. The function auto-detects format (JSON or HTML). Custom nodes with a render function produce the function’s output; nodes without one pass through as <crap-node> custom elements.

-- In an after_read hook
function hooks.render_content(context)
    local doc = context.doc
    if doc.content then
        doc.content = crap.richtext.render(doc.content)
    end
    return context
end

Custom node attributes listed in searchable_attrs are automatically extracted for full-text search when the field uses JSON format.

Resize Behavior

By default, the richtext editor is vertically resizable (no max-height constraint). Set admin.resizable = false to lock it to a fixed height range (200–600px):

crap.fields.richtext({
    name = "content",
    admin = {
        resizable = false,
    },
})

Admin Rendering

Renders as a ProseMirror-based rich text editor with a configurable toolbar. When custom nodes are configured, an insert button group appears in the toolbar for each node type. Nodes display as styled cards (block) or pills (inline) in the editor; double-click to edit attributes.

Notes

  • No server-side sanitization is applied — sanitize in hooks if needed
  • The toolbar configuration only affects the admin UI; it does not validate or strip content server-side
  • Custom node names must be alphanumeric with underscores only