@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
| Kind | Tags |
|---|---|
| Blocks | paragraph, heading (h1–h6), blockquote, bullet list, numbered list, code block, horizontal rule, image |
| Marks | bold (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.