| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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}; |
|
|
| |
| |
| |
| |
| |
| |
| |
| pub struct StreamdownRenderer<W: Write> { |
| parser: Parser, |
| renderer: Renderer<W>, |
| line_buffer: String, |
| } |
|
|
| impl<W: Write> StreamdownRenderer<W> { |
| |
| pub fn new(writer: W, width: usize) -> Self { |
| Self { |
| parser: Parser::new(), |
| renderer: Renderer::new(writer, width), |
| line_buffer: String::new(), |
| } |
| } |
|
|
| |
| 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(), |
| } |
| } |
|
|
| |
| |
| |
| 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(()) |
| } |
|
|
| |
| |
| 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); |
| } |
| } |
|
|