#![forbid(unsafe_code)]
mod block;
mod inline;
mod sink;
mod vocab;
pub mod width;
use block::Engine;
use sink::{PartsSink, StringSink};
use vocab::Vocab;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Channel {
TelegramHtml,
SlackMarkdown,
GithubMarkdown,
NotionMarkdown,
Plain,
Html,
}
impl Channel {
pub fn name(self) -> &'static str {
match self {
Channel::TelegramHtml => "telegram-html",
Channel::SlackMarkdown => "slack-markdown",
Channel::GithubMarkdown => "github-markdown",
Channel::NotionMarkdown => "notion-markdown",
Channel::Plain => "plain",
Channel::Html => "html",
}
}
pub fn all() -> [Channel; 6] {
[
Channel::TelegramHtml,
Channel::SlackMarkdown,
Channel::GithubMarkdown,
Channel::NotionMarkdown,
Channel::Plain,
Channel::Html,
]
}
pub fn parse(name: &str) -> Option<Channel> {
Self::all().into_iter().find(|c| c.name() == name)
}
pub fn limit(self) -> usize {
match self {
Channel::TelegramHtml => 4096,
Channel::SlackMarkdown | Channel::Plain => 12_000,
Channel::GithubMarkdown => 65_536,
Channel::NotionMarkdown => 65_536,
Channel::Html => usize::MAX,
}
}
}
pub const MIN_LIMIT: usize = 256;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Options {
pub limit: Option<usize>,
pub html: HtmlOptions,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HtmlOptions {
pub line_breaks: LineBreaks,
pub images: Images,
pub schemes: Option<Vec<String>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LineBreaks {
#[default]
Br,
Space,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Images {
#[default]
Link,
Load,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Repairs {
pub closed_emphasis: usize,
pub closed_fence: usize,
pub reverted_code_span: usize,
pub dropped_marker: usize,
pub escaped_char: usize,
pub tag_emphasis: usize,
pub stripped_html: usize,
pub rewritten_bullet: usize,
pub rewritten_table: usize,
pub converted_marker: usize,
}
impl Repairs {
pub(crate) fn add(&mut self, other: Repairs) {
self.closed_emphasis += other.closed_emphasis;
self.closed_fence += other.closed_fence;
self.reverted_code_span += other.reverted_code_span;
self.dropped_marker += other.dropped_marker;
self.escaped_char += other.escaped_char;
self.tag_emphasis += other.tag_emphasis;
self.stripped_html += other.stripped_html;
self.rewritten_bullet += other.rewritten_bullet;
self.rewritten_table += other.rewritten_table;
self.converted_marker += other.converted_marker;
}
pub fn any(&self) -> bool {
self.closed_emphasis + self.closed_fence + self.reverted_code_span + self.dropped_marker > 0
}
pub fn changed(&self) -> bool {
*self != Repairs::default()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rendered {
pub parts: Vec<String>,
pub repairs: Repairs,
}
pub struct Streamer {
engine: Engine,
buf: String,
started: bool,
tail: String,
dirty: bool,
revised: bool,
}
impl Streamer {
pub fn new(channel: Channel) -> Self {
Self::with_options(channel, Options::default())
}
pub fn with_options(channel: Channel, options: Options) -> Self {
Self {
engine: Engine::new(channel, &options),
buf: String::new(),
started: false,
tail: String::new(),
dirty: true,
revised: true,
}
}
pub fn repairs(&self) -> Repairs {
self.engine.repairs()
}
fn trim_leading(&mut self, out: &mut String, from: usize) {
if self.started {
return;
}
let fresh = &out[from..];
let keep = fresh.len() - fresh.trim_start_matches('\n').len();
if keep > 0 {
out.drain(from..from + keep);
}
if out.len() > from {
self.started = true;
}
}
pub fn push(&mut self, chunk: &str) -> &str {
let mut buf = std::mem::take(&mut self.buf);
buf.clear();
self.push_into(chunk, &mut buf);
self.buf = buf;
&self.buf
}
pub fn push_into(&mut self, chunk: &str, out: &mut String) {
self.dirty |= !chunk.is_empty();
let from = out.len();
let mut sink = StringSink(out);
self.engine.feed(chunk, &mut sink);
self.trim_leading(out, from);
}
pub fn finish(&mut self) -> &str {
let mut buf = std::mem::take(&mut self.buf);
buf.clear();
self.finish_into(&mut buf);
self.buf = buf;
&self.buf
}
pub fn finish_into(&mut self, out: &mut String) {
let from = out.len();
let mut sink = StringSink(out);
self.engine.finish(&mut sink);
self.trim_leading(out, from);
self.revised = self.dirty || self.tail != out[from..];
self.tail.clear();
self.dirty = false;
}
pub fn preview(&mut self) -> &str {
if !self.dirty {
return &self.tail;
}
let mut tail = std::mem::take(&mut self.tail);
tail.clear();
self.engine.preview(&mut StringSink(&mut tail));
if !self.started {
let keep = tail.len() - tail.trim_start_matches('\n').len();
tail.drain(..keep);
}
self.tail = tail;
self.dirty = false;
&self.tail
}
pub fn preview_into(&mut self, out: &mut String) {
out.push_str(self.preview());
}
pub fn revised(&self) -> bool {
self.revised
}
pub fn close_open(&self, out: &mut String) {
self.engine.close_open(out);
}
}
pub fn render(input: &str, channel: Channel) -> Vec<String> {
render_with(input, channel, Options::default()).parts
}
pub fn render_with(input: &str, channel: Channel, options: Options) -> Rendered {
let mut engine = Engine::new(channel, &options);
let mut sink = PartsSink::new(Vocab::from_options(channel, &options));
engine.feed(input, &mut sink);
engine.finish(&mut sink);
Rendered { repairs: engine.repairs(), parts: sink.into_parts() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_limits_are_channel_specific() {
assert_eq!(Channel::TelegramHtml.limit(), 4096);
assert_eq!(Channel::SlackMarkdown.limit(), 12_000);
assert_eq!(Channel::GithubMarkdown.limit(), 65_536);
}
#[test]
fn parts_never_end_inside_a_tag() {
let mut input = String::new();
for i in 0..40 {
input.push_str(&format!(
"- `method{i}(options?: SomeLongOptionsType{i}): string` — 주소의 뒤집힌 꼴을 \
돌려준다 [src](https://example.com/owner/repo/blob/master/src/mod{i}.ts#L{i}28)\n"
));
}
let parts = render(&input, Channel::TelegramHtml);
assert!(parts.len() > 1, "한도를 넘겨서 나뉘어야 하는 입력이다");
for (i, p) in parts.iter().enumerate() {
assert_eq!(
p.matches('<').count(),
p.matches('>').count(),
"조각 {i} 가 태그 한가운데서 끊겼다: …{}",
&p[p.len().saturating_sub(40)..]
);
assert!(p.chars().count() <= Channel::TelegramHtml.limit(), "조각 {i} 가 한도를 넘었다");
}
}
}