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

@luckydraw/blex

v0.1.23

Published

Interactive content block rendering library — framework-agnostic, streaming-aware

Readme

@luckydraw/blex

A TypeScript library for rendering interactive content blocks from JSON. Framework-agnostic, streaming-aware, with lazy-loaded heavy renderers.

Used by cumulus (AI chat) to display rich content inline in conversations, and by plastic (app platform) for charts, dashboards, and application view components.

Install

npm install @luckydraw/blex

Distribution

Two build outputs from the same source:

  • ESM — import { renderBlock } from '@luckydraw/blex' — for bundled consumers (plastic, janus)
  • IIFE — <script src="blex.min.js"> → window.Blex — for cumulus widget (no build step)
  • Chart standalone — blex-chart.min.js / import { renderChart } from '@luckydraw/blex/chart'

Target: ~11KB core (gzipped), heavy renderers lazy-loaded on first use.

Quick Start

import { renderBlock, registerBlockType } from '@luckydraw/blex';

// Render a block into a container
const handle = await renderBlock(
  { type: 'confirm', id: 'c1', data: { message: 'Deploy to production?' } },
  document.getElementById('container')
);

// Listen for interactions
handle.onInteraction((interaction) => {
  console.log(interaction.type);       // 'click'
  console.log(interaction.serialized); // 'User confirmed: Deploy to production?'
});

// Clean up
handle.destroy();

Chart Subpath Export

A standalone chart API shared between cumulus and plastic. Fully independent — no side effects, no DOMPurify, no registry setup:

import { renderChart } from '@luckydraw/blex/chart';

const chart = renderChart(container, {
  type: 'bar',
  data: {
    labels: ['Q1', 'Q2', 'Q3', 'Q4'],
    datasets: [{ label: 'Revenue', values: [10, 25, 15, 30] }]
  },
  options: { title: 'Quarterly Revenue', legend: true }
});

chart.update({ data: { labels: ['Q1', 'Q2'], datasets: [{ label: 'Revenue', values: [10, 25] }] } });
chart.destroy();

API

MediaLightbox

Hosts can reuse the block viewer for attachments and Markdown images. Import MediaLightbox and the MediaItem type from @luckydraw/blex, or use new window.Blex.MediaLightbox() with the IIFE bundle.

import { MediaLightbox, type MediaItem } from '@luckydraw/blex';

const viewer = new MediaLightbox();
const items: MediaItem[] = [{ url: '/media/screenshot.png', alt: 'Screenshot' }];
// Inside a host button's click handler; index is zero-based:
viewer.open(items, 0, previewButton, 'Attachments');
// When the host view unmounts or its media is removed:
viewer.close();

open(items, index, opener, title?) initializes styles itself, even before any block is rendered. opener is the focusable host element used for theme and focus restoration. title defaults to Media viewer. Items have url, optional type: 'image' | 'video' (default image), alt, plain-text caption, and poster. Opening, navigating, and closing emit no block interaction. Omit the optional fifth onAdd(index) callback for viewing only; providing it adds an explicit “Add to response” button that calls the host callback only on that button click.

Host and block viewers share one active dialog per document. Opening another closes the previous one. close() is safe to repeat and stops/unloads video; call it before removing the host view or revoking media object URLs. Native <dialog> support is required. Load one Blex bundle per document so all callers share viewer ownership.

renderBlock(block, container, options?): Promise<BlockHandle>

Resolves the block type from the registry, loads the renderer (lazy if needed), renders into the container, and wires up interaction callbacks.

Options:

  • onReady?: () => void — called when the block finishes initial render
  • allowMediaAttachments?: boolean — set false to hide gallery “Add to response” controls in render-only views. Viewing and navigation remain available.

renderPlaceholder(type, container): HTMLElement

Renders a typed skeleton/spinner for a block that hasn't finished streaming yet. Returns the placeholder element for later replacement. Used by cumulus while buffering ~~~blex:TYPE fence content.

parseBlockData(type, source): BlockDataParseResult

Parse the JSON payload of a completed block fence. Available from the main ESM export and window.Blex. Consumers own fence extraction and streaming state. Strict JSON passes unchanged; this is not renderer schema validation.

For invalid table JSON, Blex can insert missing row property names using the declared columns. A damaged row must have exactly one cell per unique, nonempty column, an explicit first-column key, and any other explicit keys in their declared positions. Only string/number cells are recovered. For example, {"Concept":"captain","A person"} with columns ['Concept', 'Meaning'] becomes {"Concept":"captain","Meaning":"A person"}. Ambiguous mappings, truncated JSON, and other syntax errors fall back without guessing.

Results always retain source exactly:

  • Success: { ok: true, data, source, repaired, repairs, repairedSource? }. Each repair has { code: 'table-row-missing-key', row, column, offset }, where row is zero-based and offset is a UTF-16 index into original source.
  • Failure: { ok: false, source, error }. error is the original native JSON diagnostic, for debugging; display original content to the reader.

Recovery is limited to 1,000,000 source code units, 100,000 tokens, and 64 nested levels. These limits apply after strict parsing fails. No model or network calls occur. Persist original source, even when the displayed block was repaired.

renderBlockSourceFallback(type, source, container): HTMLElement

Replaces container content with an open disclosure showing the exact source as wrapped plain text. HTML in source/type is inert. Returns the disclosure and sets data-blex-ready="true", data-loading="false", and aria-busy="false". Destroy any existing block handle before replacing its content.

const parsed = parseBlockData(type, source);
if (!parsed.ok) {
  renderBlockSourceFallback(type, parsed.source, container);
} else {
  try {
    const handle = await renderBlock({ type, id, data: parsed.data }, container);
    // Retain handle for interactions and destroy() during cleanup.
  } catch {
    // Valid JSON can still contain data that a renderer cannot display.
    renderBlockSourceFallback(type, parsed.source, container);
  }
}

registerBlockType(type, renderer): void

Register a custom block type renderer. Overrides built-in renderers if the type matches.

registerBlockType('my-widget', {
  render(block, container) { /* ... */ },
  update(block) { /* streaming updates */ },
  onInteraction(callback) { /* wire up events */ },
  destroy() { /* cleanup */ }
});

Types

interface ContentBlock<T = unknown> {
  type: string;
  data: T;
  id: string;         // unique per block instance, stable across re-renders
  streaming?: boolean; // true while data is still arriving
}

interface BlockRenderer<T = unknown> {
  render(block: ContentBlock<T>, container: HTMLElement): void;
  update?(block: ContentBlock<T>): void;
  onInteraction?(callback: (interaction: BlockInteraction) => void): void;
  destroy(): void;
}

interface BlockHandle {
  update(block: ContentBlock): void;
  onInteraction(callback: (interaction: BlockInteraction) => void): void;
  destroy(): void;
}

interface BlockInteraction {
  blockId: string;
  type: string;        // 'select', 'click', 'submit', 'apply', 'reject', 'move'
  payload: unknown;
  serialized: string;  // pre-formatted text for chat input injection
}

Chart Types

interface ChartConfig {
  type: 'bar' | 'line' | 'pie' | 'doughnut';
  data: { labels: string[]; datasets: DataSet[] };
  options?: { title?: string; legend?: boolean; responsive?: boolean };
}

interface DataSet {
  label: string;
  values: number[];
  color?: string;
}

interface ChartInstance {
  update(config: Partial<ChartConfig>): void;
  destroy(): void;
}

Built-in Block Types

Core Blocks (bundled, <5KB each)

| Type | Description | Interaction Types | |------|-------------|-------------------| | confirm | Yes/No/Cancel buttons | click | | poll | Single/multi choice with optional write-in | submit | | status | Key/value pairs with status indicators (ok/warning/error) | — | | metric | Single big number with label and trend indicator | — | | image | URL/base64 image with caption, zoom/download | click | | svg | Sanitized inline SVG with download/copy | click | | table | Sortable columns, row/cell/column selection | select | | code | Syntax-highlighted code with line selection | select | | diff | Unified or side-by-side diff | apply, reject | | file-tree | Expandable directory tree | select | | form | JSON schema → dynamic form | submit | | progress | Live-updating step tracker | — | | terminal | Collapsible command output with exit code | click | | timeline | Vertical event timeline with timestamps | — |

Lazy-loaded Blocks

| Type | Description | Loaded Size | |------|-------------|-------------| | mermaid | Diagram rendering via mermaid.js | ~200KB | | chart | Bar/line/pie/doughnut via chart library | ~60KB | | kanban | Drag-and-drop columns via HTML5 DnD | ~8KB | | calendar | Month/week/day event views | ~12KB | | gallery | Image/video grid or carousel with a shared lightbox | ~7KB |

Heavy renderers are loaded on first use. Subsequent renders of the same type reuse the loaded module.

Gallery images preserve their natural aspect ratios in both grid and carousel layouts. Grid columns (default 3) is a maximum: the grid reduces its column count to keep images at least 240px wide where space permits, and fits a single column to smaller containers. A gallery with fewer images expands them to fill the row.

Visual blocks and lightbox

Click a standalone image, gallery thumbnail, SVG, Mermaid diagram, or chart to open the shared large lightbox. Openers also work with Enter and Space. Viewing never selects the block or adds anything to the response. SVG and Mermaid remain vectors, including Mermaid HTML labels; chart previews show the current canvas image. Updates and destruction close the preview. The standalone chart API does not add a lightbox.

Gallery viewing supports Previous/Next buttons and Left/Right arrows; video controls retain their native seek/volume keys. Close with Escape, the Close button, or a click outside the panel. Focus returns to the thumbnail or Zoom button. Images retain their proportions; videos have native controls and never autoplay. Closing, navigating, updating, or destroying the block stops and unloads video.

The existing images array accepts both images and videos:

const handle = await renderBlock({
  type: 'gallery', id: 'field-notes', data: {
    title: 'Field notes', layout: 'grid', columns: 3,
    images: [
      { url: '/media/photo.jpg', alt: 'Mountain landscape', caption: 'Day one' },
      { type: 'video', url: '/media/clip.webm', poster: '/media/poster.jpg', caption: 'Trail footage' },
    ],
  },
}, container);
handle.onInteraction(interaction => addChip(interaction));

type defaults to image; use explicit type: 'video' for video URLs, including opaque URLs. poster is optional: a video without one displays a “View video” placeholder. Video bytes are requested only when the viewer opens that entry. Supported codecs and seeking depend on the browser and media server; hosts are responsible for MIME types and HTTP range support.

Only Add to response (below each item and inside the lightbox) emits a deferred select interaction. Image payloads remain { imageIndex, url }; video payloads add mediaType: 'video'. imageIndex always indexes the images array. Both use immediate: false and revertable: true, with no local selection state to revert. Opening, navigating, playing, and closing never emit. Standalone image blocks keep their local Zoom and Download actions.

Attachment buttons stay hidden until handle.onInteraction() is registered. For a render-only host that registers a no-op callback, pass { allowMediaAttachments: false } to renderBlock. With a no-op callback and the default option, Add emits harmlessly but cannot attach anything or show a success state; hosts should suppress that action. React's <BexBlock> accepts the same allowMediaAttachments prop and automatically hides actions without onInteraction.

The viewer uses native <dialog>.showModal() in the browser top layer, mounted on document.body, with the originating block's Blex colors and font. It can appear above clipped/isolated chat containers and host dialogs. Native dialog support is required. Ataran owns plugin integration; this feature does not replace host-owned attachment thumbnails or file previews.

Interactive test IDs use blex-gallery-open-{blockId}-{index}, blex-gallery-add-{blockId}-{index}, and blex-image-open-{blockId}. The single active viewer uses blex-lightbox, blex-lightbox-close, blex-lightbox-prev, blex-lightbox-next, blex-lightbox-add, and blex-lightbox-media. SVG, Mermaid, and chart openers use blex-svg-content-{blockId}, blex-mermaid-diagram-{blockId}, and blex-chart-open-{blockId} respectively.

Video embeds

embed blocks recognize direct .mp4, .m4v, .webm, and .ogv URL paths (case insensitive, with query strings/fragments allowed) and display native video controls after click-to-load. Videos fill the block width, contain the whole image, and cap the requested height (default 400px) at 70% of viewport height, with a 160px minimum for controls. They do not autoplay or emit chat interactions. Updating or destroying the block stops and unloads playback.

{"url":"/media/recording.mp4","title":"Interaction recording","height":480}

For opaque video URLs, use mediaType: "video". To force an application URL with a video-looking path to remain an iframe, use mediaType: "iframe". Other URLs still use the sandboxed iframe and its existing postMessage bridge. No HTML player wrapper is needed. A non-video response sent to the native player shows a loading error; Blex never retries it as unsandboxed HTML.

Hosts remain responsible for correct MIME types, codecs, range responses, and deployment of the Blex bundle. Uploaded HTML should retain its sandbox policy; native <video src> does not require removing that policy from media responses. Use blex-embed-video-{blockId} and blex-embed-status-{blockId} for testing.

Unknown Types

Any unrecognized block type renders as raw JSON inside a collapsible <details> element, so content is never lost.

Theming

blex uses CSS custom properties with sensible defaults. Consumers override them to match their theme:

/* blex defaults — override in your app */
--blex-bg: #ffffff;
--blex-text: #1a1a1a;
--blex-border: #e5e5e5;
--blex-accent: #3b82f6;
--blex-success: #22c55e;
--blex-warning: #f59e0b;
--blex-error: #ef4444;
--blex-chart-bg: var(--blex-bg);
--blex-chart-text: var(--blex-text);
--blex-animation-duration: 200ms;

Dark mode: Set data-theme="dark" on a parent element. blex adjusts defaults automatically.

Test mode: Set data-test-mode="true" or --blex-animation-duration: 0ms to disable all animations for puppet/browser testing.

Streaming

Renderers that implement update() receive incremental data without a full destroy/re-render cycle. The streaming flag on ContentBlock indicates data is still arriving.

const handle = await renderBlock(
  { type: 'table', id: 't1', data: { rows: [] }, streaming: true },
  container
);

// As data arrives:
handle.update({ type: 'table', id: 't1', data: { rows: newRows }, streaming: true });

// When complete:
handle.update({ type: 'table', id: 't1', data: { rows: allRows }, streaming: false });

When streaming: false (e.g., historical message replay), blocks render immediately with no transition/animation.

Interaction Serialization

Every BlockInteraction includes a serialized field — a pre-formatted text string suitable for injecting into a chat input. Each block type controls its own serialization format.

// Table selection might serialize as:
{
  blockId: 't1',
  type: 'select',
  payload: { rows: [0, 2], columns: ['name', 'status'] },
  serialized: 'Selected from table:\n| name | status |\n| Alice | active |\n| Charlie | pending |'
}

Consumers receive interactions via the BlockHandle.onInteraction() callback and can forward serialized text directly to the chat input.

Cleanup Contract

destroy() is a hard guarantee: after calling it, zero references remain. This includes:

  • Event listeners (click, resize, keyboard)
  • Animation frames (RAF callbacks)
  • Canvas contexts
  • Resize/intersection/mutation observers
  • Timers (setTimeout, setInterval)
  • Pending lazy-load promises
  • DOM references

Enforced via BaseRenderer abstract class with cleanup tracking. All built-in renderers extend it.

Design Decisions

  • Vanilla JS/TS — no React dependency. Consumers wrap as needed.
  • Dual output — ESM for bundled consumers, IIFE (window.Blex) for cumulus widget.
  • DOMPurify — the one required runtime dependency, used for SVG/HTML sanitization.
  • Lazy loading — heavy renderers (mermaid, chart.js, kanban, calendar, gallery) loaded on first use.
  • Subpath export — @luckydraw/blex/chart is fully independent: no side effects, no registry, no DOMPurify. Just renderChart() + Chart.js lazy load.
  • CSS custom properties — --blex-* variables for theming. No hardcoded colors.
  • data-testid — on all interactive elements for puppet/browser automation.
  • BaseRenderer — abstract class enforcing destroy() cleanup contract.

Package Structure

src/
├── index.ts          # main entry: renderBlock, registerBlockType, renderPlaceholder
├── types.ts          # ContentBlock, BlockRenderer, BlockInteraction, BlockHandle
├── registry.ts       # block type registry + lazy loader
├── base-renderer.ts  # BaseRenderer abstract class with cleanup tracking
├── fallback.ts       # unknown type fallback renderer
├── placeholder.ts    # streaming skeleton/placeholder utility
├── theme.ts          # CSS custom property defaults + dark mode
├── chart/
│   └── index.ts      # subpath export: renderChart (fully independent)
└── renderers/
    ├── confirm.ts
    ├── poll.ts
    ├── status.ts
    ├── metric.ts
    ├── image.ts
    ├── mermaid.ts    # lazy-loaded
    ├── svg.ts
    ├── table.ts
    ├── chart.ts      # lazy-loaded, wraps chart/index.ts
    ├── code.ts
    ├── diff.ts
    ├── file-tree.ts
    ├── form.ts
    ├── progress.ts
    ├── terminal.ts
    ├── timeline.ts
    ├── kanban.ts     # lazy-loaded
    ├── calendar.ts   # lazy-loaded
    └── gallery.ts    # lazy-loaded

License

MIT