DAVAUX
Alpha

@davaux/rich-text

A WYSIWYG rich text editor built on ProseMirror, with content stored and loaded as OML — the same serializable JSX-tree format used everywhere else in Davaux. No separate markdown/HTML column, no separate parser for rich text fields.

Installation

npm install @davaux/rich-text

Setup

1. Register the plugin

Add richTextPlugin() to davaux.config.ts. This registers the editor's reactive components for client-side hydration, the same way @davaux/ui's uiPlugin() does for its own components:

// davaux.config.ts
import { defineConfig } from 'davaux/config'
import { richTextPlugin } from '@davaux/rich-text/plugin'

export default defineConfig({
  plugins: [richTextPlugin()],
})

2. Import the stylesheet

// src/routes/_layout.tsx
import '@davaux/ui/prose.css'
import '@davaux/rich-text/styles.css'

The editor renders its content with the dv-prose class, so @davaux/ui's prose.css needs to be present alongside @davaux/rich-text's own stylesheet (toolbar, editing chrome).

Basic usage

import { RichTextEditor } from '@davaux/rich-text'
import type { OmlNode } from 'davaux/oml'

function ArticleForm({ initialBody }: { initialBody: OmlNode }) {
  return (
    <RichTextEditor
      value={initialBody}
      onChange={(oml) => console.log('updated OML:', oml)}
    />
  )
}

value is the current OML content — pass null for an empty document. onChange fires with the updated OML tree on every edit; it's optional if you only need the editor for its form integration (below).

Using it as a form field

Pass name to render a hidden <input> that's kept in sync with the current OML as JSON. This drops the editor straight into a native <form method="post"> — no client-side fetch/submit wiring required:

<form method="post">
  <RichTextEditor name="body" value={initialBody} placeholder="Write your post…" />
  <button type="submit">Publish</button>
</form>

On the server, parse the submitted field back into OML before storing it — parseOml validates the shape and throws a descriptive error on malformed input:

import { parseOml } from 'davaux/oml'

const body = parseOml(JSON.parse(field.body))

A body field may hold either fresh OML JSON or legacy plain text from before you adopted the editor — parseRichText handles both, so you don't need to hand-roll the JSON.parse/fallback dance yourself:

import { parseRichText } from '@davaux/rich-text'

const body = parseRichText(field.body) // OmlNode | null

It returns null for an empty/undefined value, parses valid OML JSON, and falls back to wrapping raw text as a single paragraph if parsing fails — safe to call on anything you've ever stored in that column, old or new.

Headless mode

Pass hideToolbar to render the editing surface without the built-in toolbar — useful if you want to build custom controls against the same conversion functions:

<RichTextEditor value={initialBody} hideToolbar />

Rendering stored content without the editor

Use RichTextReader to display stored content anywhere without pulling in ProseMirror at all. It takes the raw stored string directly — the same JSON RichTextEditor's hidden input produces, or legacy plain text — and parses it internally via parseRichText:

import { RichTextReader } from '@davaux/rich-text'

<RichTextReader value={article.body} />

This is the right choice for read-only views (article pages, previews) — only routes that actually need editing should import RichTextEditor.

Stripping links

Pass options={{ links: false }} to render link marks as plain text instead of <a> elements. This matters when the reader itself sits inside an outer link — a feed card whose whole body links through to the full article, for example — where a nested <a> inside the rendered content would produce invalid, unpredictably-clickable nested anchors:

<Anchor href={articleHref} style="color:inherit">
  <RichTextReader value={article.body} options={{ links: false }} />
</Anchor>

@davaux/ui's Spoiler component has a matching href prop for exactly this pattern — it wraps clamped content in a built-in link instead of an expand/collapse toggle, which pairs naturally with links: false here since neither leaves a nested interactive control inside the outer link.

Plain-text excerpts

richTextExcerpt flattens stored content down to plain text — useful for feed previews, search indexing, or checking whether a body actually has content:

import { richTextExcerpt } from '@davaux/rich-text'

const hasContent = richTextExcerpt(field.body).length > 0

Supported formatting

KindTags
Blocksparagraph, heading (h1–h6), blockquote, bullet list, numbered list, code block, horizontal rule, image
Marksbold (strong), italic (em), underline (u), strikethrough (s), inline code (code), link (a)

Marks nest as regular OML elements — bold text is simply a <strong> element wrapping a #text node, the same way HTML nests inline formatting. No OML schema changes are required to store rich text alongside any other Davaux content, and overlapping marks always nest in a fixed order (link → bold → italic → underline → strike → code) so round-tripping through the editor is deterministic.

Converters

docToOml, omlToDoc, and the shared ProseMirror schema are exported directly if you need to work with the document yourself — for example, building a custom toolbar or running a migration over stored content:

import { docToOml, omlToDoc, schema } from '@davaux/rich-text'

const doc = omlToDoc(storedOml, schema)
const oml = docToOml(doc)

See the OML guide for more on the underlying tree format.