npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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:

  1. Subtractable — drop a module by not importing it.
  2. Replaceable — swap a default renderer with no leftover bytes.
  3. 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-compose

Peer 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 kept

Module 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