@haklex/rich-compose
v0.42.1
Published
Compose primitives for haklex rich content renderers — Gundam-style assembly of Lexical nodes, sync/lazy renderers, and Provider stacks.
Readme
@haklex/rich-compose
Compose primitives for haklex rich content — Gundam-style assembly of Lexical nodes, sync/lazy renderers, edit-side decorators, and Provider stacks. Powers both composeRenderer (read-only) and composeEditor (editable).
Why
The legacy @haklex/rich-kit-shiro/renderer (now removed) shipped every default renderer eagerly: overriding CodeBlock or LinkCard left the original shiki and LinkCardRenderer chains in the bundle. rich-compose solves three problems at once:
- Subtractable — drop a module by not importing it.
- Replaceable — swap a default renderer with no leftover bytes.
- Extensible — add new renderer slots (sync or lazy) without touching the package.
Tree-shake is enforced by physical subpath isolation (./modules/<name>/node separates Klasses from heavy renderer code; ./modules/<name> vs ./modules/<name>/edit separates static-only from editor-only chains) plus ESM-only emit.
Installation
pnpm add @haklex/rich-composePeer Dependencies
| Package | Version |
| ---------------------------- | --------- |
| react / react-dom | >=19 |
| lexical / @lexical/react | ^0.45.0 |
| @haklex/rich-editor | workspace |
Per-module upstream packages (@haklex/rich-ext-*, @haklex/rich-renderer-*) are optional peers — install only those you compose.
Quick start
Read-only renderer
import { composeRenderer } from '@haklex/rich-compose/core';
import { allRendererModules } from '@haklex/rich-compose/renderer';
const RichContent = composeRenderer({ modules: allRendererModules });
// Render
<RichContent value={editorState} theme="light" variant="article" />;Or cherry-pick:
import { composeRenderer } from '@haklex/rich-compose/core';
import { embedModule } from '@haklex/rich-compose/modules/embed';
import { codeBlockModule } from '@haklex/rich-compose/modules/code-block';
const RichContent = composeRenderer({ modules: [embedModule, codeBlockModule] });Editor
import { composeEditor } from '@haklex/rich-compose';
import { allEditorModules } from '@haklex/rich-compose/editor';
const RichEditor = composeEditor({ modules: allEditorModules });
// Use like @haklex/rich-editor's RichEditor — accepts the same props plus children for plugins.
<RichEditor initialValue={state} onChange={setState} variant="article" />;Both helpers return memoized React components. composeEditor internally wires composeRenderer for nested editor states.
Three consumer modes
Mode A — defaults
Import the module sugar. Klass + renderer + (optional) lazy/SSR fallback are wired automatically.
import { embedModule } from '@haklex/rich-compose/modules/embed';
import { embedEditModule } from '@haklex/rich-compose/modules/embed/edit';Mode B — custom renderer (tree-shake the default)
Import the Klass from /node; supply your own renderer.
import { GalleryNode } from '@haklex/rich-compose/modules/gallery/node';
const myGalleryModule: RichRendererModule = {
name: 'gallery',
nodes: [GalleryNode],
renderers: { Gallery: MyGalleryRenderer },
};For renderer-only modules (no custom Klass), construct with matching name:
const myLinkCardModule: RichRendererModule = {
name: 'link-card',
renderers: { LinkCard: MyLinkCardRenderer },
};Mode C — wrap the default
Pull the default renderer from /renderer and wrap.
import { LinkCardRenderer } from '@haklex/rich-compose/modules/link-card/renderer';
const wrappedModule: RichRendererModule = {
name: 'link-card',
renderers: {
LinkCard: (props) => (
<div className="extra-wrap">
<LinkCardRenderer {...props} />
</div>
),
},
};Module shapes
RichRendererModule (read-only) and RichEditorModule (editor superset).
interface RichRendererModule {
name: string; // dedup key
nodes?: Klass<LexicalNode>[]; // base Klasses
renderers?: Partial<RendererConfig>; // sync renderer map
Provider?: ComponentType<{ children: ReactNode }>; // shared provider
lazyRenderers?: Partial<{
[K in RendererKey]: () => Promise<{ default: NonNullable<RendererConfig[K]> }>;
}>;
ssrFallback?: Partial<Record<RendererKey, ReactNode>>;
}
interface RichEditorModule extends RichRendererModule {
editNodes?: Klass<LexicalNode>[]; // edit-side subclasses (override base by getType)
editRenderers?: Partial<RendererConfig>; // override renderers in editor mode
EditorProvider?: ComponentType<{ children: ReactNode }>; // editor-only provider
plugins?: ReactNode; // module-owned editor plugins
nestedEditorPlugins?: ReactNode; // plugins mounted inside LexicalNestedComposer surfaces
actions?: ReactNode; // module-owned action UI
}composeRenderer consumes RichRendererModule[]; composeEditor consumes RichEditorModule[]. Each editor module spreads its renderer-only counterpart, so allEditorModules is a strict superset of allRendererModules.
Dedup rules
modules:
reference seen → skip silently
same name → warn (dev), replace previous module entirely
else → append
nodes:
reference seen → skip
subclass override → edit Klass replaces base Klass (same getType, A extends B)
unrelated collision → throw at compose time
(would break instanceof across module boundaries)Lazy modules
code-block and mermaid ship lazy by default with deterministic ssrFallbacks. excalidraw's Klass code-splits internally via its own decorate().
To override a lazy renderer (e.g., pre-tokenized code block for SSR), pass overrides:
composeRenderer({
modules: allRendererModules,
overrides: { CodeBlock: PreTokenizedCodeBlock },
});The lazy chunk is still emitted but never fetched at runtime.
Platform-agnostic rendering
RichRenderer (and composeRenderer) emit DOM by default, but every DOM touchpoint is injectable so the same tree walk can target another React renderer (React Native, ink, a test harness):
| Hook | Covers |
| ------------------------------- | --------------------------------------------------------------------------------------------- |
| builtinNodeOverrides[type] | Element nodes (paragraph, heading, list, …) and decorator nodes (image, mermaid, …) |
| builtinNodeOverrides.text | Text leaves — receives the serialized text node (text, format, style) |
| blockAnchor(el, blockId, key) | Root-level block wrapper carrying $.blockId; defaults to a data-block-id div |
| as | Host container — any ElementType; non-string hosts receive only style and children |
A decorator override receives the default decoration as its single child and may drop it:
<RichRenderer
as={View}
blockAnchor={(el, blockId, key) => (
<View key={key} nativeID={blockId}>
{el}
</View>
)}
builtinNodeOverrides={{
text: (node, key) => (
<Text key={key} style={textStyle(node.format)}>
{node.text}
</Text>
),
paragraph: (_node, key, children) => <Text key={key}>{children}</Text>,
mermaid: (node, key) => <NativeMermaid key={key} diagram={node.diagram} />,
}}
value={state}
/>Decorator slots (rendererConfig / module renderers) accept any React component, so Image, CodeBlock, LinkCard etc. can be replaced the same way.
Module catalog
| Module | Base Klass | Edit Klass | Mode | Source |
| -------------- | ----------------- | --------------------- | ------------------------------- | ------------------------- |
| alert | builtin | — | sync | rich-renderer-alert |
| banner | builtin | — | sync | rich-renderer-banner |
| chat | ChatNode | ChatEditNode | sync | rich-ext-chat |
| code-block | builtin | — | lazy + ssr fallback | rich-renderer-codeblock |
| code-snippet | CodeSnippetNode | CodeSnippetEditNode | sync | rich-ext-code-snippet |
| embed | EmbedNode | EmbedEditNode | sync (via node decorate) | rich-ext-embed |
| excalidraw | ExcalidrawNode | ExcalidrawEditNode | via node decorate | rich-ext-excalidraw |
| gallery | GalleryNode | GalleryEditNode | sync | rich-ext-gallery |
| image | builtin | — | sync | rich-renderer-image |
| katex | — | builtin | edit-only (no static renderer) | rich-renderer-katex |
| link-card | builtin | — | sync | rich-renderer-linkcard |
| mention | builtin | — | sync | rich-renderer-mention |
| mermaid | builtin | — | lazy + ssr fallback | rich-renderer-mermaid |
| nested-doc | NestedDocNode | NestedDocEditNode | recursive (via composeRenderer) | rich-ext-nested-doc |
| poll | PollNode | PollEditNode | sync | rich-ext-poll |
| ruby | builtin | — | sync | rich-renderer-ruby |
| video | builtin | — | sync | rich-renderer-video |
katex ships only an edit module — KaTeX rendering for the read-only side is wired by the host renderer via decorate().
Sub-path exports
| Path | Description |
| ---------------------------------------------- | ---------------------------------------------------------------------------- |
| @haklex/rich-compose | compatibility barrel: core helpers plus aggregate conveniences |
| @haklex/rich-compose/core | renderer composition core: composeRenderer, RichRenderer, renderer types |
| @haklex/rich-compose/renderer | aggregate barrel — every renderer module + allRendererModules |
| @haklex/rich-compose/editor | aggregate barrel — every editor module + allEditorModules |
| @haklex/rich-compose/modules/<name> | renderer-side module barrel |
| @haklex/rich-compose/modules/<name>/edit | editor-side module (extends the renderer module) |
| @haklex/rich-compose/modules/<name>/node | Klass(es) only (when applicable) |
| @haklex/rich-compose/modules/<name>/renderer | default renderer only |
| @haklex/rich-compose/style.css | all-in-one CSS bundle — prose body + tokens + every module |
| @haklex/rich-compose/style/foundation.css | prose body + theme tokens + variant classes (no modules) |
| @haklex/rich-compose/style/table.css | built-in table renderer |
| @haklex/rich-compose/style/<name>.css | per-module CSS (alert, banner, image, video, …) |
Aggregate barrels are convenient defaults; the fine-grained /modules/<name> and /modules/<name>/edit subpaths stay available for dynamic-import and selective inclusion.
CSS strategy
Two consumer patterns:
All-in-one (default). One import covers everything rich-compose can render:
import '@haklex/rich-compose/style.css';Fine-grained (advanced). Use this when you've overridden one or more default renderers and want to drop their CSS from your bundle. Import foundation.css plus the modules you keep — never reach into rich-renderer-* or rich-ext-* packages directly:
import '@haklex/rich-compose/style/foundation.css';
import '@haklex/rich-compose/style/alert.css';
import '@haklex/rich-compose/style/image.css';
import '@haklex/rich-compose/style/ruby.css';
// …only modules whose default renderer you keptModule subpaths mirror the JS module subpaths (modules/<name> ↔ style/<name>.css).
Why not auto-injected via import side effects
Earlier versions tried to side-effect-import each module's CSS from inside modules/<name>/index.ts. Combined with sideEffects: ["**/*.css"] (which marks .mjs files as side-effect-free) and bundler optimizations such as Next.js optimizePackageImports, those bare CSS imports were tree-shaken away — modules rendered but their styling was missing. The explicit subpath model above eliminates that footgun: the consumer states which CSS they want, and the bundler honors it deterministically.
Part of Haklex
This package is part of the Haklex rich editor ecosystem.
License
MIT
