Skip to main content

llm_markdown_renderer 0.2.1 copy "llm_markdown_renderer: ^0.2.1" to clipboard
llm_markdown_renderer: ^0.2.1 copied to clipboard

Renders Markdown streamed token by token from an LLM without jank. Incremental parsing, stable partial code fences, tables and lists, syntax highlighting.

llm_markdown_renderer #

pub package pub points likes CI codecov license: MIT

Render Markdown that is streamed token by token from an LLM without jank.

A chat app streaming Markdown with code blocks, tables and lists

Try the live demo

  • Incremental parsing. Each chunk costs time proportional to the chunk plus the one block that is still being written. Finished blocks are never parsed again and are not rebuilt by Flutter.
  • Stable while streaming. Unclosed code fences, half-arrived tables, lists and dangling ** render as a sensible preview that does not flicker or re-layout as the rest arrives.
  • Syntax-highlighted code blocks with a language label and a copy button. Highlighting is incremental too, so a 5,000-line block stays cheap.
  • Two APIs: feed a MarkdownStreamController with append(chunk), or hand the widget a Stream<String>.
  • Themeable through a ThemeExtension (MarkdownTheme), light and dark.
  • No dependencies beyond Flutter. Pure Dart, no native code: Android, iOS, Web (including WebAssembly), Windows, macOS and Linux.
  • Accessible and international: semantic headings and lists, per-paragraph right-to-left detection (Arabic, Hebrew), large text sizes.
  • The parser is Flutter-free (package:llm_markdown_renderer/parser.dart), so you can also use it from plain Dart.

Install #

flutter pub add llm_markdown_renderer

Quick start #

Controller API #

final controller = MarkdownStreamController();

// as tokens arrive from your model / SSE / websocket:
llmTokens.listen(controller.append, onDone: controller.complete);

// in build():
StreamingMarkdown(controller: controller)

Call controller.reset() to clear the view (for "regenerate"), and controller.dispose() when you are done. append is cheap and can be called hundreds of times per second: listeners are notified at most once per frame.

Stream API #

StreamingMarkdown.fromStream(stream: llmTokens)

The widget owns the subscription and completes the document when the stream ends. Keep the Stream instance stable across rebuilds; passing a different one starts over. Handle failures with onStreamError.

Static content #

const StreamingMarkdown.static(data: '# Hello **world**')

Connecting a provider #

Recipes for Firebase AI Logic (firebase_ai), OpenAI-compatible servers and the Anthropic API (server-sent events) are in doc/integrations.md. Anything that yields text chunks works.

StreamingMarkdown(
  controller: controller,
  onTapLink: (url, title) => launchUrl(Uri.parse(url)), // your choice of plugin
  imageBuilder: (context, url, alt) => Image.network(url), // opt in to images
  highlighter: const CodeHighlighter.defaults(),
)
  • Links are always styled, but only http, https, mailto, tel and relative URLs are tappable; javascript: and friends are inert. The package never launches anything itself.
  • Images are not fetched by default (model output could point anywhere); an alt-text placeholder is shown until you supply an imageBuilder.
  • Replace the code block entirely with codeBlockBuilder, or reuse CodeBlockView in your own layout.

How it differs from re-rendering the whole text #

The usual way to show a streamed answer is to pass the growing string to a Markdown widget on every update. That re-parses and re-lays-out the entire message for every token, so the work per token grows with the length of the answer, and unfinished syntax flashes in and out: a stray **, a code fence that is not closed yet, a table that looks like text until its second line.

This package is designed around the streaming case instead:

  • Work per chunk does not grow with the message. Only the block being written is parsed again; see the numbers below.
  • A chunking guarantee, enforced by tests. For every way of splitting the input into chunks (down to a single character, splitting fences, \r\n pairs and emoji), the result equals parsing the input in one go.
  • Finished blocks are never rebuilt. They are the same objects in every snapshot and their widgets are reused by identity.
  • No dependencies.

Performance #

benchmark/append_cost.dart compares what one streamed chunk costs with what re-parsing the whole text costs, as the document grows (each "block" is a heading, a paragraph, a list or a code block):

blocks text size incremental, per chunk full re-parse, per chunk
40 2 KB 60 µs 0.24 ms
400 17 KB 53 µs 1.0 ms
4,000 171 KB 57 µs 9.0 ms
40,000 1.7 MB 234 µs 104 ms

Measured with dart run (JIT) on an Apple M5, Dart 3.13.2. Absolute numbers will differ on your machine; the shape is what matters. The incremental cost is flat until the very large documents, where reading the snapshot (a copy of the list of block references, no parsing) starts to show. These numbers cover the parser; widget build and layout of the block being written are on top of that, and finished blocks add nothing.

Reproduce:

dart run benchmark/append_cost.dart

Tips: pass stable callbacks and builders to StreamingMarkdown (new closures on every parent build make the configuration look changed and rebuild finished blocks), and keep selectable: true only where you need it.

Theming #

MarkdownTheme is a ThemeExtension. Without configuration it is derived from your ThemeData; to customise, register it:

final base = ThemeData(colorSchemeSeed: Colors.teal);
MaterialApp(
  theme: base.copyWith(extensions: [
    MarkdownTheme.fromTheme(base).copyWith(
      blockSpacing: 18,
      codeBlockBorderRadius: 12,
      syntaxTheme: SyntaxTheme.light().copyWith(styles: {
        TokenKind.keyword: const TextStyle(color: Colors.deepPurple),
      }),
    ),
  ]),
);

Resolution order: the widget's theme: argument, then the ThemeData extension, then a theme derived from the ambient ThemeData.

Syntax highlighting #

Built in (pure Dart, rule based, no dependencies): Dart, JavaScript/TypeScript, JSON, Python, Bash, YAML, SQL, HTML/XML, CSS, C, C++, Java, Kotlin, Swift, Go, Rust, C#, and diffs, plus common aliases (js, py, sh, yml, …). Unknown languages render as plain monospace.

Add or override a language:

final highlighter = RuleBasedHighlighter(extraLanguages: [
  LanguageDefinition(name: 'toy', states: {
    'root': [LexRule(r'\bgo\b', TokenKind.keyword)],
  }),
]);

or implement the CodeHighlighter interface to plug in another engine. The interface is line-oriented with an opaque carried state, which is what makes incremental highlighting possible.

What "stable while streaming" means #

While it is arriving… …you see
``` (opening fence) a code block shell (header + copy button) as soon as the 3rd backtick lands; a partial `` paints nothing
unclosed code fence the block, growing append-only; partial closing fence is never painted as code
table header only a paragraph, until the delimiter row (|---|) arrives
partial table row a full-width row, missing cells empty; column widths never shrink
**bold / *em / `code already styled (speculatively closed); a trailing ** is hidden
[label](http://ex just the label; it becomes a link when ) arrives
-, 1., # alone on a line nothing, until there is something to show

Using the parser without Flutter #

import 'package:llm_markdown_renderer/parser.dart';

final parser = IncrementalMarkdownParser();
parser.append('# Hi\n\nSome **bo');
print(parser.document.blocks); // [Heading1(...), Paragraph(...)]
parser.append('ld**');
parser.complete();

Supported Markdown, limitations and FAQ #

Supported: ATX/setext headings, paragraphs, emphasis/strong/strikethrough, inline code, links, images, autolinks, fenced and indented code, block quotes, nested ordered/unordered lists, task lists, thematic breaks, GFM tables, backslash escapes and common entities.

Not supported yet

  • LaTeX math ($…$, $$…$$). Models emit it often. It is the most likely next feature, as an opt-in hook so the package does not take a math dependency. Until then it is shown as plain text.
  • Reference-style links ([text][ref] with a [ref]: url definition). They need the whole document, which is at odds with incremental parsing.
  • Raw HTML. It is never interpreted; it is shown as text.
  • Footnotes, definition lists and other extensions.

Deliberate differences from CommonMark, to keep every append cheap:

  • Tight/loose is decided per list item, not per list.
  • A block quote is re-parsed as a whole while it is the open block.
  • A sub-bullet indented by only 2 spaces under 1. is treated as nested, as most LLMs intend.

FAQ

Does it work with SelectionArea? StreamingMarkdown wraps its content in one by default (selectable: true). Set selectable: false if you provide your own.

Can I render a message that is already complete? Yes: StreamingMarkdown.static.

Why are my images not showing? Images are opt-in; see "Links, images, code".

How do I report a rendering bug? Open an issue with the Markdown and, if you can, the chunk sizes you streamed it in. See CONTRIBUTING.md.

Example #

example/ is a chat app with a fake LLM that streams canned answers in irregular 1–12 character chunks (so boundaries fall inside fences, tables and links), with speed control, light/dark toggle, regenerate, a stress test and a frame-time readout.

cd example && flutter run

Architecture #

How the parser, the widgets and the guarantees fit together is described in ARCHITECTURE.md.

License #

MIT, see LICENSE.

1
likes
160
points
--
downloads
screenshot

Documentation

API reference

Publisher

verified publisherashitanema.dev

Weekly Downloads

Renders Markdown streamed token by token from an LLM without jank. Incremental parsing, stable partial code fences, tables and lists, syntax highlighting.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#markdown #llm #streaming #chat #syntax-highlighting

License

MIT (license)

Dependencies

flutter

More

Packages that depend on llm_markdown_renderer