flomark
v0.0.6
Published
Render markdown while it is still streaming.
Maintainers
Readme
flomark
Render markdown while it is still streaming.
flomark is built for LLM chat interfaces where an assistant message arrives as small text chunks. It incrementally processes an append-only markdown string, keeps completed blocks stable, and only updates the live trailing block while tokens are still arriving.
Why
Most markdown renderers assume the full document is available. In an LLM chat app, the document is constantly growing, and reparsing or rerendering the whole assistant message on every token can become wasteful.
flomark provides a small framework-agnostic streaming core with React and Solid adapters on top. The adapters share the same parser behavior while integrating with each framework's rendering model.
Design Criteria
- Incrementally process append-only markdown as chunks arrive.
- Use
marked's lexer for markdown tokenization. - Keep finalized blocks stable so completed content does not rerender on every token.
- Re-tokenize only the mutable trailing region while streaming.
- Share one framework-agnostic core across framework adapters.
- Provide ergonomic React and Solid APIs without splitting them into separate packages.
- Render basic semantic HTML nodes only, with no styling included.
- Escape raw HTML by default and block unsafe URL schemes in default renderers.
Bring your own CSS, typography system, syntax highlighter, or component overrides if you want a specific visual style.
Install
npm install flomarkCore API
Use the core parser directly when you want framework-independent control over a markdown stream.
import { StreamingMarkdownParser } from "flomark";
const parser = new StreamingMarkdownParser();
parser.write("# Hello\n\nThis is stream");
parser.write("ing markdown.");
const { frozen, pending } = parser.snapshot();
parser.end();A snapshot contains:
frozen: completed markdown blocks whose identity and rendered output stay stable.pending: the trailing in-progress block, if any, which may change as more text arrives.
Each block exposes the original marked token used to render it. flomark does not invent a second markdown AST.
import type { MarkdownBlock } from "flomark";
function inspect(block: MarkdownBlock) {
console.log(block.token.type);
}React
Use StreamingMarkdown when your app stores each assistant message as one growing string. Append each model token to message.content, then set isStreaming to false when the response finishes.
import { StreamingMarkdown } from "flomark/react";
export function AssistantMessage({ message }) {
return (
<article className="prose">
<StreamingMarkdown
content={message.content}
isStreaming={message.isStreaming}
resetKey={message.id}
/>
</article>
);
}For direct control over chunks, use the hook and render the frozen/pending split yourself.
import { StreamingBlocks, useStreamingMarkdown } from "flomark/react";
export function AssistantMessage() {
const { frozen, pending, write, end, reset } = useStreamingMarkdown();
// Call write(token) as chunks arrive.
// Call end() when the model finishes.
// Call reset() before reusing the hook for a different message.
return <StreamingBlocks frozen={frozen} pending={pending} />;
}Solid
Use the Solid renderer the same way: pass a cumulative, append-only markdown string to StreamingMarkdown.
import { StreamingMarkdown } from "flomark/solid";
export function AssistantMessage(props) {
return (
<article class="prose">
<StreamingMarkdown
content={props.message.content}
isStreaming={props.message.isStreaming}
resetKey={props.message.id}
/>
</article>
);
}Hook-based Solid usage exposes accessors for the completed blocks and pending block.
import { StreamingBlocks, useStreamingMarkdown } from "flomark/solid";
export function AssistantMessage() {
const { frozen, pending, write, end, reset } = useStreamingMarkdown();
// Call write(token) as chunks arrive.
// Call end() when the model finishes.
// Call reset() before reusing the hook for a different message.
return <StreamingBlocks frozen={frozen()} pending={pending()} />;
}Streaming Model
<StreamingMarkdown content> expects content to grow by appending new text. This matches most chat apps, where each model token is appended to the current assistant message.
When switching to a different message or replacing the text instead of appending to it, pass a new resetKey so flomark starts with a fresh parser instance.
<StreamingMarkdown
content={message.content}
isStreaming={message.streaming}
resetKey={message.id}
/>When isStreaming becomes false, the trailing in-progress block is finalized.
Markdown can be ambiguous while text is still arriving. A paragraph may become a list, a table may need another line, and a fenced code block may remain open until its closing fence arrives. For that reason, flomark only freezes blocks that are confidently complete; the trailing block may be re-tokenized and rerendered until the stream ends.
Custom Rendering
Pass a components map to override how individual markdown token types render.
Custom components receive the original marked token as token, along with any already-sanitized props that the default renderer would use for that node.
<StreamingMarkdown
content={content}
isStreaming={isStreaming}
components={{
code: ({ token }) => (
<pre data-language={token.lang || undefined}>
<code>{token.text}</code>
</pre>
),
link: ({ href, title, children, token }) => (
<a href={href} title={title} data-token-type={token.type}>
{children}
</a>
),
}}
/>Component names match marked token types where practical, such as paragraph, heading, list, list_item, blockquote, code, codespan, strong, em, link, image, table, hr, and br.
Safety
Default renderers are safe for untrusted model output:
- Raw HTML markdown is rendered as escaped text, not inserted as real HTML.
- Dangerous
hrefandsrcschemes are dropped. - Allowed URL forms are
http:,https:,mailto:,tel:, same-page fragments, and relative URLs. javascript:,data:,vbscript:, and other unknown schemes are blocked.
If you override components, keep using the sanitized props passed by flomark when possible. If you render token.raw, call dangerouslySetInnerHTML, or otherwise insert untrusted model output as real HTML, sanitize it yourself first.
Package Shape
import { StreamingMarkdownParser } from "flomark";
import { StreamingMarkdown } from "flomark/react";
import { StreamingMarkdown } from "flomark/solid";React and Solid are optional peer dependencies. Importing one adapter should not require installing or bundling the other.
