File size: 8,292 Bytes
1851bae | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | //! Forge Markdown Stream - Streaming markdown renderer for terminal output.
//!
//! This crate provides a streaming markdown renderer optimized for LLM output.
//! It renders markdown with syntax highlighting, styled headings, tables,
//! lists, and more.
//!
//! # Example
//!
//! ```no_run
//! use forge_markdown_stream::StreamdownRenderer;
//! use std::io;
//!
//! fn main() -> io::Result<()> {
//! let mut renderer = StreamdownRenderer::new(io::stdout(), 80);
//!
//! // Push tokens as they arrive from LLM
//! renderer.push("Hello ")?;
//! renderer.push("**world**!\n")?;
//!
//! // Finish rendering
//! let _ = renderer.finish()?;
//! Ok(())
//! }
//! ```
mod code;
mod heading;
mod inline;
mod list;
mod renderer;
mod repair;
mod style;
mod table;
mod theme;
mod utils;
use std::io::{self, Write};
pub use renderer::Renderer;
pub use repair::repair_line;
pub use streamdown_parser::Parser;
pub use theme::{Style, Theme};
/// Streaming markdown renderer for terminal output.
///
/// Buffers incoming tokens and renders complete lines with syntax highlighting,
/// styled headings, tables, lists, and more.
///
/// The renderer is generic over the writer type `W`, which must implement
/// `Write`.
pub struct StreamdownRenderer<W: Write> {
parser: Parser,
renderer: Renderer<W>,
line_buffer: String,
}
impl<W: Write> StreamdownRenderer<W> {
/// Create a new renderer with the given writer and terminal width.
pub fn new(writer: W, width: usize) -> Self {
Self {
parser: Parser::new(),
renderer: Renderer::new(writer, width),
line_buffer: String::new(),
}
}
/// Create a new renderer with a custom theme.
pub fn with_theme(writer: W, width: usize, theme: Theme) -> Self {
Self {
parser: Parser::new(),
renderer: Renderer::with_theme(writer, width, theme),
line_buffer: String::new(),
}
}
/// Push a token to the renderer.
///
/// Tokens are buffered until a complete line is received, then rendered.
pub fn push(&mut self, token: &str) -> io::Result<()> {
self.line_buffer.push_str(token);
while let Some(pos) = self.line_buffer.find('\n') {
let line = self.line_buffer.get(..pos).unwrap_or("").to_string();
for repaired in repair_line(&line, self.parser.state()) {
for event in self.parser.parse_line(&repaired) {
self.renderer.render_event(&event)?;
}
}
self.line_buffer = self.line_buffer.get(pos + 1..).unwrap_or("").to_string();
}
Ok(())
}
/// Finish rendering, flushing any remaining buffered content.
/// Returns the underlying writer.
pub fn finish(mut self) -> io::Result<()> {
if !self.line_buffer.is_empty() {
for repaired in repair_line(&self.line_buffer, self.parser.state()) {
for event in self.parser.parse_line(&repaired) {
self.renderer.render_event(&event)?;
}
}
}
for event in self.parser.finalize() {
self.renderer.render_event(&event)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::StreamdownRenderer;
fn fixture_rendered_output(markdown: &str, width: usize) -> String {
let mut output = Vec::new();
let mut fixture = StreamdownRenderer::new(&mut output, width);
fixture.push(markdown).unwrap();
fixture.finish().unwrap();
let actual = strip_ansi_escapes::strip(output);
String::from_utf8(actual)
.unwrap()
.trim_matches('\n')
.to_string()
}
fn fixture_rendered_output_from_chunks(chunks: &[&str], width: usize) -> String {
let mut output = Vec::new();
let mut fixture = StreamdownRenderer::new(&mut output, width);
for chunk in chunks {
fixture.push(chunk).unwrap();
}
fixture.finish().unwrap();
let actual = strip_ansi_escapes::strip(output);
String::from_utf8(actual)
.unwrap()
.trim_matches('\n')
.to_string()
}
#[test]
fn test_streaming_renderer_preserves_korean_spacing_in_structured_markdown() {
let fixture = concat!(
"## ꡬν μμ½\n",
"- κ° μλΉμ€μμ metadata keyλ₯Ό κ°λ³ μμ νμ§ μκ³ , object storage κ³΅ν΅ λ μ΄μ΄μμ μΌκ΄ μ κ·ννλλ‘ λ°μνμ΅λλ€.\n",
"## κ²ν μ¬ν\n",
"- λ³Έ μμ μ μ
λ‘λ μ metadata header μ΄λ¦ λ¬Έμ λ₯Ό ν΄κ²°ν©λλ€.\n",
"- μΆκ°μ μΈ κΆν μ μ±
, bucket policy, reverse proxy μ νμ΄ μμΌλ©΄ λ³λ μ€λ₯κ° λ°μν μ μμ΅λλ€.\n",
);
let actual = fixture_rendered_output(fixture, 200);
let expected = concat!(
"## ꡬν μμ½\n",
"β’ κ° μλΉμ€μμ metadata keyλ₯Ό κ°λ³ μμ νμ§ μκ³ , object storage κ³΅ν΅ λ μ΄μ΄μμ μΌκ΄ μ κ·ννλλ‘ λ°μνμ΅λλ€.\n",
"\n",
"## κ²ν μ¬ν\n",
"β’ λ³Έ μμ μ μ
λ‘λ μ metadata header μ΄λ¦ λ¬Έμ λ₯Ό ν΄κ²°ν©λλ€.\n",
"β’ μΆκ°μ μΈ κΆν μ μ±
, bucket policy, reverse proxy μ νμ΄ μμΌλ©΄ λ³λ μ€λ₯κ° λ°μν μ μμ΅λλ€.",
);
assert_eq!(actual, expected);
}
#[test]
fn test_streaming_renderer_preserves_korean_spacing_when_structured_tail_arrives_in_chunks() {
let fixture = [
"## κ²ν κ²°κ³Ό\n",
"- λ³Έ μ¬λ‘λ μ€νΈλ¦¬λ° λ§ν¬λ€μ΄ λ λλ§μ 곡백 μ¬μ‘°ν© λ¬Έμ μ κ΄λ ¨μ΄ μμ΅λλ€.\n",
"- ν΅μ¬ ꡬνμ 곡백 보쑴 λνΌμ μμΉν©λλ€.\n",
"- νκ· ν
μ€νΈλ μ€νΈλ¦¬λ° λ λλ¬ κ²μ¦ νλͺ©μ μΆκ°λμ΄ μμ΅λλ€.\n\n",
"νμ μμ
μ λ€μκ³Ό κ°μ΅λλ€.\n",
"1. λ³κ²½ μ¬νμ κ²ν κ°λ₯ν νμμΌλ‘ μ 리ν©λλ€.\n",
"2. μ€μ λν μΆλ ₯κ³Ό μ μ¬ν ν΅ν© ν
μ€νΈ λ²μλ₯Ό ",
"νμ₯ν©λλ€.",
];
let actual = fixture_rendered_output_from_chunks(&fixture, 200);
let expected = concat!(
"## κ²ν κ²°κ³Ό\n",
"β’ λ³Έ μ¬λ‘λ μ€νΈλ¦¬λ° λ§ν¬λ€μ΄ λ λλ§μ 곡백 μ¬μ‘°ν© λ¬Έμ μ κ΄λ ¨μ΄ μμ΅λλ€.\n",
"β’ ν΅μ¬ ꡬνμ 곡백 보쑴 λνΌμ μμΉν©λλ€.\n",
"β’ νκ· ν
μ€νΈλ μ€νΈλ¦¬λ° λ λλ¬ κ²μ¦ νλͺ©μ μΆκ°λμ΄ μμ΅λλ€.\n",
"\n",
"νμ μμ
μ λ€μκ³Ό κ°μ΅λλ€.\n",
"1. λ³κ²½ μ¬νμ κ²ν κ°λ₯ν νμμΌλ‘ μ 리ν©λλ€.\n",
"2. μ€μ λν μΆλ ₯κ³Ό μ μ¬ν ν΅ν© ν
μ€νΈ λ²μλ₯Ό νμ₯ν©λλ€.",
);
assert_eq!(actual, expected);
}
#[test]
fn test_streaming_renderer_wraps_blockquotes_with_prefix_width_and_long_tokens() {
let fixture = "> supercalifragilistic\n> νκΈ κ³΅λ°±\n";
let actual = fixture_rendered_output(fixture, 10);
let expected = concat!(
"β supercal\n",
"β ifragili\n",
"β stic\n",
"β νκΈ\n",
"β 곡백"
);
assert_eq!(actual, expected);
}
#[test]
fn test_streaming_renderer_wraps_blockquote_links_without_losing_separator() {
let fixture = "> [λ§ν¬](https://example.com/very/long/path) μ€λͺ
\n";
let actual = fixture_rendered_output(fixture, 20);
let expected = concat!(
"β λ§ν¬\n",
"β (https://example.c\n",
"β om/very/long/path)\n",
"β μ€λͺ
"
);
assert_eq!(actual, expected);
}
#[test]
fn test_streaming_renderer_wraps_nested_blockquotes_with_correct_prefix_width() {
let fixture = ">> supercalifragilistic\n";
let actual = fixture_rendered_output(fixture, 12);
let expected = concat!("β β supercal\n", "β β ifragili\n", "β β stic");
assert_eq!(actual, expected);
}
}
|