Skip to content

OpticLM/streamup

Repository files navigation

@opticlm/streamup

A simple, headless React streaming markdown renderer.

Install

pnpm add @opticlm/streamup react react-dom

Usage

import { Streamup } from '@opticlm/streamup'

// Basic
<Streamup>{'# Hello **world**'}</Streamup>

// Streaming (incomplete markdown)
<Streamup streaming>{'**bold text without clos'}</Streamup>

// Custom components
<Streamup
  components={{
    h1: ({ children }) => <h1 className="text-4xl font-bold">{children}</h1>,
    p: ({ children }) => <p className="my-4 leading-relaxed">{children}</p>,
    code: ({ children, className, ...props }) => {
      if ('data-block' in props) {
        const { 'data-language': language } = props
        return (
          <pre className="bg-zinc-900 p-4 rounded">
            <code className={className}>{children}</code>
          </pre>
        )
      }
      return <code className="bg-zinc-100 px-1 rounded">{children}</code>
    },
  }}
>
  {markdown}
</Streamup>

Stable streaming rate

Streamup renders whatever string you pass each render. If your source emits tokens in bursts (e.g. an LLM delivering 50 tokens at once, then idling for 200 ms), pace the source — Streamup will not buffer or smooth on its own.

With the AI SDK useChat hook, set experimental_throttle (milliseconds):

const { messages } = useChat({ experimental_throttle: 50 }) // ~20 fps cap

Lower values feel more live; higher values are smoother and cheaper. 30–80 ms is the useful range for chat UIs. The same idea applies to any custom source: coalesce deltas at a fixed cadence before they reach <Streamup>.

Internally, Streamup uses useDeferredValue so a heavy tail block (table, code block, etc.) cannot block input or scroll, and only re-heals the last block on each chunk — keeping per-chunk cost flat as the buffer grows.

What's included by default

All parsing is always enabled:

  • GFM: tables, strikethrough, task lists, autolinks
  • Math: block equtions
  • CJK: proper emphasis, strikethrough, and autolink handling for Chinese/Japanese/Korean text
  • HTML: raw HTML with sanitization
  • Footnotes: reference and definition syntax

KaTeX rendering

Math syntax is always parsed. To render it with KaTeX, add the plugin:

pnpm add katex rehype-katex
import { Streamup } from '@opticlm/streamup'
import { katex } from '@opticlm/streamup/katex'
import 'katex/dist/katex.min.css'

// Default options
<Streamup plugins={[katex()]}>{'$$E = mc^2$$'}</Streamup>

// Custom KaTeX options
<Streamup plugins={[katex({ errorColor: '#ff0000', strict: false })]}>
  {'$$E = mc^2$$'}
</Streamup>

Mermaid diagrams

pnpm add mermaid
import { Streamup } from '@opticlm/streamup'
import { withMermaid } from '@opticlm/streamup/mermaid'

// Default
<Streamup components={withMermaid()}>
  {'```mermaid\ngraph TD\n  A-->B\n```'}
</Streamup>

// With mermaid config (theme, flowchart direction, etc.)
<Streamup components={withMermaid({ config: { theme: 'dark' } })}>
  {'```mermaid\ngraph TD\n  A-->B\n```'}
</Streamup>

// With custom fallback code component
<Streamup components={withMermaid({
  config: { theme: 'forest' },
  fallbackCode: MyCodeBlock,
})}>
  {markdown}
</Streamup>

Or use the renderer directly:

import { MermaidRenderer } from '@opticlm/streamup/mermaid'

<MermaidRenderer code="graph TD; A-->B" config={{ theme: 'dark' }} />

Configuration

Single-dollar math

By default, only $$...$$ block math is enabled. Enable $...$ inline math:

<Streamup singleDollarTextMath>{markdown}</Streamup>

URL transform

Transform or remove URLs before rendering:

<Streamup urlTransform={(url) => url.replace('http:', 'https:')}>
  {markdown}
</Streamup>

// Remove all links
<Streamup urlTransform={() => null}>{markdown}</Streamup>

Element filtering

Filter which elements are allowed to render. Returning false removes the element but keeps its children:

<Streamup allowElement={(el) => el.tagName !== 'img'}>
  {markdown}
</Streamup>

Custom sanitization

Replace the default sanitization schema. Spread defaultSanitizeSchema to extend it:

import { Streamup, defaultSanitizeSchema } from '@opticlm/streamup'

<Streamup sanitizeSchema={{
  ...defaultSanitizeSchema,
  attributes: {
    ...defaultSanitizeSchema.attributes,
    div: ['className', 'style'],
  },
}}>
  {markdown}
</Streamup>

Custom remark/rehype plugins

Add custom remark or rehype plugins via the plugin system:

import type { StreamupPlugin } from '@opticlm/streamup'
import myRemarkPlugin from 'remark-my-plugin'
import myRehypePlugin from 'rehype-my-plugin'

const myPlugin: StreamupPlugin = {
  remarkPlugins: [myRemarkPlugin],
  rehypePlugins: [[myRehypePlugin, { option: true }]],
}

<Streamup plugins={[myPlugin]}>{markdown}</Streamup>

Utilities

import { remend, parseMarkdownIntoBlocks, processMarkdown } from '@opticlm/streamup'

// Heal incomplete markdown (streaming)
remend('**bold')        // '**bold**'
remend('~~strike')      // '~~strike~~'
remend('[link](http')   // '[link](streamup:incomplete-link)'

// Split markdown into blocks for incremental rendering
parseMarkdownIntoBlocks('# Title\n\nParagraph')
// ['# Title\n\n', 'Paragraph\n']

// Low-level: process markdown to React elements
processMarkdown('# Hello', {
  components: myComponents,
  processorOptions: { singleDollarTextMath: true },
  urlTransform: (url) => url,
})

Props

Prop Type Default Description
children string '' Markdown content
streaming boolean false Heal incomplete markdown before rendering
components Partial<Components> unstyled HTML Override rendering for any element
plugins StreamupPlugin[] [] Add remark/rehype plugins (e.g. katex())
className string Class on the wrapper <div>
singleDollarTextMath boolean false Enable $...$ inline math syntax
sanitizeSchema SanitizeSchema built-in Custom HTML sanitization schema
urlTransform UrlTransform Transform or remove URLs
allowElement AllowElement Filter elements (return false to remove)

Styling GFM task lists

GFM task lists are rendered with class names from remark-gfm:

  • ul.contains-task-list — the list container
  • li.task-list-item — each task item (contains an <input type="checkbox" disabled>)
ul.contains-task-list {
  list-style: none;
  padding-left: 0;
}

li.task-list-item {
  display: flex;
  align-items: baseline;
  gap: 0.5em;
}

Components you can override

Every HTML element can be overridden. Each component receives its standard HTML props plus node (the HAST element).

Common overrides: h1h6, p, a, img, code, pre, blockquote, table, thead, tbody, tr, th, td, ol, ul, li, hr, strong, em, del, sup, sub.

Code components

Block code (```lang) and inline code (`code`) both render as <code>, distinguished by the data-block prop set by the rehype plugin:

  • data-block — present (empty string) on block code, absent on inline code
  • data-language — the language identifier (e.g. "js", "python"), only on block code

License

MIT

About

A simple, headless, streaming-compatible markdown renderer for React.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors