pub mod block;
pub mod cursor;
pub mod escape;
pub mod footnote;
pub mod inline;
pub mod limits;
pub mod link_ref;
#[cfg(feature = "mdx")]
pub mod mdx;
#[cfg(feature = "profiling")]
#[doc(hidden)]
pub mod profiling;
pub mod range;
pub mod render;
pub use block::{Alignment, BlockEvent, BlockParser, CalloutType, CodeBlockKind, fixup_list_tight};
pub use footnote::FootnoteStore;
pub use inline::{InlineEvent, InlineParser};
pub use link_ref::{LinkRefDef, LinkRefStore};
pub use range::Range;
pub use render::HtmlWriter;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FencedCodeBlock<'a> {
pub language: Option<&'a str>,
pub code: &'a str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrustedHtml(String);
impl TrustedHtml {
#[must_use]
pub fn from_trusted(html: impl Into<String>) -> Self {
Self(html.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_string(self) -> String {
self.0
}
}
pub trait FencedCodeRenderer {
fn render(&mut self, block: FencedCodeBlock<'_>) -> Option<TrustedHtml>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RenderPolicy {
#[default]
Untrusted,
Trusted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Options {
pub render_policy: RenderPolicy,
pub allow_html: bool,
pub allow_link_refs: bool,
pub tables: bool,
pub strikethrough: bool,
pub highlight: bool,
pub superscript: bool,
pub subscript: bool,
pub task_lists: bool,
pub autolink_literals: bool,
pub disallowed_raw_html: bool,
pub footnotes: bool,
pub front_matter: bool,
pub heading_ids: bool,
pub math: bool,
pub callouts: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Profile {
Essentials,
Extended,
Full,
}
impl Default for Options {
fn default() -> Self {
Self {
render_policy: RenderPolicy::Untrusted,
allow_html: true,
allow_link_refs: true,
tables: true,
strikethrough: true,
highlight: false,
superscript: false,
subscript: false,
task_lists: true,
autolink_literals: false,
disallowed_raw_html: true,
footnotes: false,
front_matter: false,
heading_ids: true,
math: false,
callouts: true,
}
}
}
impl From<Profile> for Options {
fn from(profile: Profile) -> Self {
match profile {
Profile::Essentials => Self {
render_policy: RenderPolicy::Untrusted,
allow_html: false,
allow_link_refs: false,
tables: true,
strikethrough: true,
highlight: false,
superscript: false,
subscript: false,
task_lists: true,
autolink_literals: false,
disallowed_raw_html: false,
footnotes: false,
front_matter: false,
heading_ids: false,
math: false,
callouts: false,
},
Profile::Extended => Self::default(),
Profile::Full => Self {
render_policy: RenderPolicy::Untrusted,
allow_html: true,
allow_link_refs: true,
tables: true,
strikethrough: true,
highlight: true,
superscript: true,
subscript: true,
task_lists: true,
autolink_literals: true,
disallowed_raw_html: true,
footnotes: true,
front_matter: true,
heading_ids: true,
math: true,
callouts: true,
},
}
}
}
pub struct ParseResult<'a> {
pub html: String,
pub front_matter: Option<&'a str>,
}
fn extract_front_matter(input: &str) -> Option<(&str, usize)> {
let bytes = input.as_bytes();
if bytes.len() < 3 {
return None;
}
let delim_char = match bytes[0] {
b'-' | b'+' => bytes[0],
_ => return None,
};
if bytes.len() < 3 || bytes[1] != delim_char || bytes[2] != delim_char {
return None;
}
let mut pos = 3;
if pos < bytes.len() && bytes[pos] == delim_char {
return None;
}
while pos < bytes.len() && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
pos += 1;
}
if pos >= bytes.len() {
return None;
}
if bytes[pos] == b'\r' {
pos += 1;
}
if pos >= bytes.len() || bytes[pos] != b'\n' {
return None;
}
pos += 1;
let content_start = pos;
loop {
if pos >= bytes.len() {
return None;
}
let line_start = pos;
if pos + 2 < bytes.len()
&& bytes[pos] == delim_char
&& bytes[pos + 1] == delim_char
&& bytes[pos + 2] == delim_char
{
let mut p = pos + 3;
if p < bytes.len() && bytes[p] == delim_char {
} else {
while p < bytes.len() && (bytes[p] == b' ' || bytes[p] == b'\t') {
p += 1;
}
let at_end = p >= bytes.len()
|| bytes[p] == b'\n'
|| (bytes[p] == b'\r' && p + 1 < bytes.len() && bytes[p + 1] == b'\n');
if at_end {
let content = &input[content_start..line_start];
let mut rest = p;
if rest < bytes.len() {
if bytes[rest] == b'\r' {
rest += 1;
}
if rest < bytes.len() && bytes[rest] == b'\n' {
rest += 1;
}
}
return Some((content, rest));
}
}
}
while pos < bytes.len() && bytes[pos] != b'\n' {
pos += 1;
}
if pos < bytes.len() {
pos += 1; }
if pos <= line_start {
break;
}
}
None
}
pub fn parse(input: &str) -> ParseResult<'_> {
let options = Options {
front_matter: true,
..Options::default()
};
parse_with_options(input, &options)
}
pub fn parse_with_options<'a>(input: &'a str, options: &Options) -> ParseResult<'a> {
let (front_matter, markdown) = if options.front_matter {
match extract_front_matter(input) {
Some((fm, offset)) => (Some(fm), &input[offset..]),
None => (None, input),
}
} else {
(None, input)
};
let html = to_html_with_options(markdown, options);
ParseResult { html, front_matter }
}
pub fn to_html(input: &str) -> String {
let mut writer = HtmlWriter::with_capacity_for(input.len());
render_to_writer(input.as_bytes(), &mut writer, &Options::default());
writer
.into_string()
.expect("rendering from a UTF-8 Markdown string must produce UTF-8 HTML")
}
pub fn to_html_into(input: &str, out: &mut Vec<u8>) {
to_html_into_with_options(input, out, &Options::default());
}
pub fn to_html_with_options(input: &str, options: &Options) -> String {
let markdown = if options.front_matter {
match extract_front_matter(input) {
Some((_, offset)) => &input[offset..],
None => input,
}
} else {
input
};
let mut writer = HtmlWriter::with_capacity_for(markdown.len());
render_to_writer(markdown.as_bytes(), &mut writer, options);
writer
.into_string()
.expect("rendering from a UTF-8 Markdown string must produce UTF-8 HTML")
}
pub fn to_html_with_renderer(
input: &str,
options: &Options,
renderer: &mut dyn FencedCodeRenderer,
) -> String {
let markdown = if options.front_matter {
match extract_front_matter(input) {
Some((_, offset)) => &input[offset..],
None => input,
}
} else {
input
};
let mut writer = HtmlWriter::with_capacity_for(markdown.len());
render_to_writer_with_renderer(markdown.as_bytes(), &mut writer, options, Some(renderer));
writer
.into_string()
.expect("rendering from a UTF-8 Markdown string must produce UTF-8 HTML")
}
pub fn to_html_into_with_options(input: &str, out: &mut Vec<u8>, options: &Options) {
let markdown = if options.front_matter {
match extract_front_matter(input) {
Some((_, offset)) => &input[offset..],
None => input,
}
} else {
input
};
out.clear();
out.reserve(markdown.len() + markdown.len() / 4);
let mut writer = HtmlWriter::with_capacity(0);
std::mem::swap(writer.buffer_mut(), out);
render_to_writer(markdown.as_bytes(), &mut writer, options);
std::mem::swap(writer.buffer_mut(), out);
}
pub fn to_html_into_with_renderer(
input: &str,
out: &mut Vec<u8>,
options: &Options,
renderer: &mut dyn FencedCodeRenderer,
) {
let markdown = if options.front_matter {
match extract_front_matter(input) {
Some((_, offset)) => &input[offset..],
None => input,
}
} else {
input
};
out.clear();
out.reserve(markdown.len() + markdown.len() / 4);
let mut writer = HtmlWriter::with_capacity(0);
std::mem::swap(writer.buffer_mut(), out);
render_to_writer_with_renderer(markdown.as_bytes(), &mut writer, options, Some(renderer));
std::mem::swap(writer.buffer_mut(), out);
}
struct ParagraphState {
content: Vec<u8>,
in_paragraph: bool,
}
impl ParagraphState {
fn new() -> Self {
Self {
content: Vec::with_capacity(256),
in_paragraph: false,
}
}
fn start(&mut self) {
self.in_paragraph = true;
self.content.clear();
}
fn add_text(&mut self, text: &[u8]) {
#[cfg(feature = "profiling")]
profiling::record_paragraph_copy(text.len());
self.content.extend_from_slice(text);
}
fn add_soft_break(&mut self) {
#[cfg(feature = "profiling")]
profiling::record_paragraph_copy(1);
self.content.push(b'\n');
}
fn finish(&mut self) -> &[u8] {
self.in_paragraph = false;
while self
.content
.last()
.is_some_and(|&b| b == b' ' || b == b'\t')
{
self.content.pop();
}
&self.content
}
}
struct HeadingState {
content: Vec<u8>,
in_heading: bool,
level: u8,
}
impl HeadingState {
fn new() -> Self {
Self {
content: Vec::with_capacity(64),
in_heading: false,
level: 0,
}
}
fn start(&mut self) {
self.in_heading = true;
self.content.clear();
}
fn add_text(&mut self, text: &[u8]) {
self.content.extend_from_slice(text);
}
fn add_soft_break(&mut self) {
self.content.push(b'\n');
}
fn finish(&mut self) -> &[u8] {
self.in_heading = false;
while self
.content
.last()
.is_some_and(|&b| b == b' ' || b == b'\t')
{
self.content.pop();
}
&self.content
}
}
struct HeadingIdTracker {
used: std::collections::HashMap<String, usize, rustc_hash::FxBuildHasher>,
slug_buf: Vec<u8>,
}
impl HeadingIdTracker {
fn new() -> Self {
Self {
used: std::collections::HashMap::with_capacity_and_hasher(
32,
rustc_hash::FxBuildHasher,
),
slug_buf: Vec::with_capacity(64),
}
}
fn make_id(&mut self, raw: &[u8]) -> &str {
generate_slug_into(raw, &mut self.slug_buf);
if self.slug_buf.is_empty() || std::str::from_utf8(&self.slug_buf).is_err() {
self.slug_buf.clear();
self.slug_buf.extend_from_slice(b"heading");
}
let slug = std::str::from_utf8(&self.slug_buf).unwrap_or("heading");
match self.used.get_mut(slug) {
Some(count) => {
*count += 1;
let n = *count;
self.slug_buf.push(b'-');
push_decimal(&mut self.slug_buf, n);
}
None => {
self.used.insert(slug.to_string(), 0);
}
}
std::str::from_utf8(&self.slug_buf).unwrap_or("heading")
}
}
fn push_decimal(buf: &mut Vec<u8>, mut n: usize) {
let mut digits = [0u8; 20];
let mut i = digits.len();
loop {
i -= 1;
digits[i] = b'0' + (n % 10) as u8;
n /= 10;
if n == 0 {
break;
}
}
buf.extend_from_slice(&digits[i..]);
}
fn generate_slug_into(raw: &[u8], slug: &mut Vec<u8>) {
slug.clear();
let mut prev_was_space = false;
for &b in raw {
if matches!(b, b'*' | b'~' | b'`' | b'[' | b']' | b'!' | b'#') {
continue;
}
if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' {
if !prev_was_space && !slug.is_empty() {
slug.push(b'-');
prev_was_space = true;
}
continue;
}
prev_was_space = false;
let ch = if b.is_ascii_uppercase() { b + 32 } else { b };
if ch.is_ascii_alphanumeric() || ch == b'-' || ch == b'_' || ch >= 0x80 {
slug.push(ch);
}
}
while slug.last() == Some(&b'-') {
slug.pop();
}
let leading = slug.iter().take_while(|&&b| b == b'-').count();
if leading > 0 {
slug.drain(..leading);
}
}
struct CellState {
content: Vec<u8>,
in_cell: bool,
}
impl CellState {
fn new() -> Self {
Self {
content: Vec::with_capacity(64),
in_cell: false,
}
}
fn start(&mut self) {
self.in_cell = true;
self.content.clear();
}
fn add_text(&mut self, text: &[u8]) {
let mut i = 0;
while i < text.len() {
if text[i] == b'\\' && i + 1 < text.len() && text[i + 1] == b'|' {
self.content.push(b'|');
i += 2;
} else {
self.content.push(text[i]);
i += 1;
}
}
}
fn finish(&mut self) -> &[u8] {
self.in_cell = false;
while self
.content
.last()
.is_some_and(|&b| b == b' ' || b == b'\t')
{
self.content.pop();
}
&self.content
}
}
struct FencedCodeState {
info: Option<Range>,
}
impl FencedCodeState {
fn new(info: Option<Range>) -> Self {
Self { info }
}
}
struct RenderContext<'a, 'r, R: FencedCodeRenderer + ?Sized> {
writer: &'a mut HtmlWriter,
inline_parser: InlineParser,
inline_events: Vec<InlineEvent>,
para_state: ParagraphState,
heading_state: HeadingState,
cell_state: CellState,
tight_list_stack: Vec<(bool, u32)>,
at_tight_li_start: bool,
need_newline_before_block: bool,
pending_loose_li_newline: bool,
blockquote_depth: u32,
in_table_head: bool,
pending_task: block::TaskState,
link_refs: &'a LinkRefStore,
footnote_store: Option<&'a FootnoteStore>,
footnote_numbers: FootnoteNumbers,
heading_id_tracker: Option<HeadingIdTracker>,
callout_stack: Vec<Option<block::CalloutType>>,
pending_footnote_backref: Option<(String, usize)>,
options: &'a Options,
fenced_code_renderer: Option<&'r mut R>,
fenced_code_state: Option<FencedCodeState>,
fenced_code_buffer: Vec<u8>,
}
impl<'a, 'r, R: FencedCodeRenderer + ?Sized> RenderContext<'a, 'r, R> {
fn new(
writer: &'a mut HtmlWriter,
link_refs: &'a LinkRefStore,
footnote_store: Option<&'a FootnoteStore>,
options: &'a Options,
fenced_code_renderer: Option<&'r mut R>,
) -> Self {
Self {
writer,
inline_parser: InlineParser::new(),
inline_events: Vec::with_capacity(64),
para_state: ParagraphState::new(),
heading_state: HeadingState::new(),
cell_state: CellState::new(),
tight_list_stack: Vec::new(),
at_tight_li_start: false,
need_newline_before_block: false,
pending_loose_li_newline: false,
blockquote_depth: 0,
in_table_head: false,
pending_task: block::TaskState::None,
link_refs,
footnote_store,
footnote_numbers: FootnoteNumbers::new(footnote_store.map_or(0, FootnoteStore::len)),
heading_id_tracker: options.heading_ids.then(HeadingIdTracker::new),
callout_stack: Vec::new(),
pending_footnote_backref: None,
options,
fenced_code_renderer,
fenced_code_state: None,
fenced_code_buffer: Vec::new(),
}
}
}
fn render_to_writer(input: &[u8], writer: &mut HtmlWriter, options: &Options) {
render_to_writer_impl::<DisabledFencedCodeRenderer>(input, writer, options, None);
}
fn render_to_writer_with_renderer(
input: &[u8],
writer: &mut HtmlWriter,
options: &Options,
fenced_code_renderer: Option<&mut dyn FencedCodeRenderer>,
) {
render_to_writer_impl(input, writer, options, fenced_code_renderer);
}
struct DisabledFencedCodeRenderer;
impl FencedCodeRenderer for DisabledFencedCodeRenderer {
fn render(&mut self, _: FencedCodeBlock<'_>) -> Option<TrustedHtml> {
unreachable!("the default render path never installs a fenced-code renderer")
}
}
fn render_to_writer_impl<R: FencedCodeRenderer + ?Sized>(
input: &[u8],
writer: &mut HtmlWriter,
options: &Options,
fenced_code_renderer: Option<&mut R>,
) {
let mut parser = BlockParser::new_with_options(input, *options);
let mut events = Vec::with_capacity((input.len() / 16).max(64));
parser.parse(&mut events);
#[cfg(feature = "profiling")]
profiling::record_block_events(&events, events.capacity());
let link_refs = parser.take_link_refs();
let footnote_store = if options.footnotes {
Some(parser.take_footnote_store())
} else {
None
};
fixup_list_tight(&mut events);
let fn_store_ref = footnote_store.as_ref();
let mut context = RenderContext::new(
writer,
&link_refs,
fn_store_ref,
options,
fenced_code_renderer,
);
for event in &events {
context.render_block_event(input, event);
}
if !context.footnote_numbers.is_empty() {
context.render_footnote_section(input);
}
}
impl<R: FencedCodeRenderer + ?Sized> RenderContext<'_, '_, R> {
fn render_block_event(&mut self, input: &[u8], event: &BlockEvent) {
let writer = &mut *self.writer;
let inline_parser = &mut self.inline_parser;
let inline_events = &mut self.inline_events;
let para_state = &mut self.para_state;
let heading_state = &mut self.heading_state;
let cell_state = &mut self.cell_state;
let tight_list_stack = &mut self.tight_list_stack;
let at_tight_li_start = &mut self.at_tight_li_start;
let need_newline_before_block = &mut self.need_newline_before_block;
let pending_loose_li_newline = &mut self.pending_loose_li_newline;
let blockquote_depth = &mut self.blockquote_depth;
let in_table_head = &mut self.in_table_head;
let pending_task = &mut self.pending_task;
let link_refs = self.link_refs;
let footnote_store = self.footnote_store;
let footnote_numbers = &mut self.footnote_numbers;
let heading_id_tracker = &mut self.heading_id_tracker;
let callout_stack = &mut self.callout_stack;
let pending_footnote_backref = &mut self.pending_footnote_backref;
let options = self.options;
let fenced_code_renderer = &mut self.fenced_code_renderer;
let fenced_code_state = &mut self.fenced_code_state;
let fenced_code_buffer = &mut self.fenced_code_buffer;
let in_tight_list = tight_list_stack
.last()
.is_some_and(|(tight, bq_depth_at_start)| {
*tight && *blockquote_depth <= *bq_depth_at_start
});
match event {
BlockEvent::ParagraphStart => {
if *pending_loose_li_newline {
writer.newline();
*pending_loose_li_newline = false;
}
if !in_tight_list {
writer.paragraph_start();
}
para_state.start();
*at_tight_li_start = false;
}
BlockEvent::ParagraphEnd => {
let in_tight_list =
tight_list_stack
.last()
.is_some_and(|(tight, bq_depth_at_start)| {
*tight && *blockquote_depth <= *bq_depth_at_start
});
let content = para_state.finish();
emit_pending_task_checkbox(pending_task, writer);
if !content.is_empty() {
render_inline_content(
content,
writer,
inline_parser,
inline_events,
link_refs,
footnote_store,
footnote_numbers,
options,
);
}
if let Some((label, number)) = pending_footnote_backref.take() {
write_footnote_backref(writer, &label, number);
}
if !in_tight_list {
writer.paragraph_end();
} else {
*need_newline_before_block = true;
}
}
BlockEvent::HeadingStart { level } => {
if *need_newline_before_block {
writer.newline();
*need_newline_before_block = false;
}
if *at_tight_li_start {
writer.newline();
*at_tight_li_start = false;
}
heading_state.start();
heading_state.level = *level;
}
BlockEvent::HeadingEnd { level } => {
let content = heading_state.finish();
if let Some(tracker) = heading_id_tracker.as_mut() {
let id = tracker.make_id(content);
writer.heading_start_with_id(*level, id);
} else {
writer.heading_start(*level);
}
if !content.is_empty() {
render_inline_content(
content,
writer,
inline_parser,
inline_events,
link_refs,
footnote_store,
footnote_numbers,
options,
);
}
writer.heading_end(*level);
}
BlockEvent::ThematicBreak => {
if *at_tight_li_start {
writer.newline();
*at_tight_li_start = false;
}
writer.thematic_break();
}
BlockEvent::HtmlBlockStart => {
if *pending_loose_li_newline {
writer.newline();
*pending_loose_li_newline = false;
}
if *at_tight_li_start {
writer.newline();
*at_tight_li_start = false;
}
}
BlockEvent::HtmlBlockText(range) => {
if options.render_policy == RenderPolicy::Untrusted {
writer.write_escaped_text(range.slice(input));
} else if options.disallowed_raw_html {
writer.write_html_filtered(range.slice(input));
} else {
writer.write_bytes(range.slice(input));
}
}
BlockEvent::HtmlBlockEnd => {}
BlockEvent::SoftBreak => {
if para_state.in_paragraph {
para_state.add_soft_break();
} else if heading_state.in_heading {
heading_state.add_soft_break();
} else {
writer.write_str("\n");
}
}
BlockEvent::Text(range) => {
let text = range.slice(input);
if para_state.in_paragraph {
para_state.add_text(text);
} else if heading_state.in_heading {
heading_state.add_text(text);
} else if cell_state.in_cell {
cell_state.add_text(text);
} else {
render_inline_content(
text,
writer,
inline_parser,
inline_events,
link_refs,
footnote_store,
footnote_numbers,
options,
);
}
}
BlockEvent::Code(range) => {
if fenced_code_state.is_some() {
fenced_code_buffer.extend_from_slice(range.slice(input));
} else {
writer.write_escaped_text(range.slice(input));
}
}
BlockEvent::VirtualSpaces(count) => {
if fenced_code_state.is_some() {
fenced_code_buffer.extend(std::iter::repeat_n(b' ', *count as usize));
} else {
for _ in 0..*count {
writer.write_byte(b' ');
}
}
}
BlockEvent::CodeBlockStart { kind } => {
if *pending_loose_li_newline {
writer.newline();
*pending_loose_li_newline = false;
}
if *at_tight_li_start {
writer.newline();
*at_tight_li_start = false;
}
match kind {
CodeBlockKind::Fenced { info } if fenced_code_renderer.is_some() => {
fenced_code_buffer.clear();
*fenced_code_state = Some(FencedCodeState::new(*info));
}
CodeBlockKind::Fenced { info } => {
writer.code_block_start(info.as_ref().map(|range| range.slice(input)));
}
CodeBlockKind::Indented => writer.code_block_start(None),
}
}
BlockEvent::CodeBlockEnd => {
if let Some(state) = fenced_code_state.take() {
let language = state
.info
.map(|range| HtmlWriter::decode_info_word(range.slice(input)));
let code = std::str::from_utf8(fenced_code_buffer)
.expect("fenced code originates from UTF-8 Markdown input");
let rendered = fenced_code_renderer.as_deref_mut().and_then(|renderer| {
renderer.render(FencedCodeBlock {
language: language.as_deref().filter(|value| !value.is_empty()),
code,
})
});
if let Some(html) = rendered {
writer.write_string(html.as_str());
} else {
writer
.code_block_start(state.info.as_ref().map(|range| range.slice(input)));
writer.write_escaped_text(fenced_code_buffer);
writer.code_block_end();
}
fenced_code_buffer.clear();
} else {
writer.code_block_end();
}
}
BlockEvent::BlockQuoteStart { callout } => {
if *pending_loose_li_newline {
writer.newline();
*pending_loose_li_newline = false;
}
if *need_newline_before_block {
writer.newline();
*need_newline_before_block = false;
}
if *at_tight_li_start {
writer.newline();
*at_tight_li_start = false;
}
*blockquote_depth += 1;
callout_stack.push(*callout);
if let Some(ct) = callout {
writer.callout_start(*ct);
} else {
writer.blockquote_start();
}
}
BlockEvent::BlockQuoteEnd => {
*blockquote_depth = blockquote_depth.saturating_sub(1);
match callout_stack.pop() {
Some(Some(_)) => writer.callout_end(),
_ => writer.blockquote_end(),
}
}
BlockEvent::ListStart { kind, tight } => {
if *pending_loose_li_newline {
writer.newline();
*pending_loose_li_newline = false;
}
if *need_newline_before_block {
writer.newline();
*need_newline_before_block = false;
}
if *at_tight_li_start {
writer.newline();
*at_tight_li_start = false;
}
tight_list_stack.push((*tight, *blockquote_depth));
match kind {
block::ListKind::Unordered => writer.ul_start(),
block::ListKind::Ordered { start, .. } => {
writer.ol_start(if *start == 1 { None } else { Some(*start) })
}
}
}
BlockEvent::ListEnd { kind, .. } => {
match kind {
block::ListKind::Unordered => writer.ul_end(),
block::ListKind::Ordered { .. } => writer.ol_end(),
}
tight_list_stack.pop();
}
BlockEvent::ListItemStart { task } => {
writer.li_start();
if !in_tight_list {
*pending_loose_li_newline = true;
} else {
*at_tight_li_start = true;
}
if options.task_lists {
*pending_task = *task;
}
}
BlockEvent::ListItemEnd => {
*at_tight_li_start = false;
*need_newline_before_block = false;
*pending_loose_li_newline = false;
*pending_task = block::TaskState::None;
writer.li_end();
}
BlockEvent::TableStart => {
if *pending_loose_li_newline {
writer.newline();
*pending_loose_li_newline = false;
}
if *need_newline_before_block {
writer.newline();
*need_newline_before_block = false;
}
if *at_tight_li_start {
writer.newline();
*at_tight_li_start = false;
}
writer.table_start();
}
BlockEvent::TableEnd => {
writer.table_end();
}
BlockEvent::TableHeadStart => {
*in_table_head = true;
writer.thead_start();
}
BlockEvent::TableHeadEnd => {
*in_table_head = false;
writer.thead_end();
}
BlockEvent::TableBodyStart => {
writer.tbody_start();
}
BlockEvent::TableBodyEnd => {
writer.tbody_end();
}
BlockEvent::TableRowStart => {
writer.tr_start();
}
BlockEvent::TableRowEnd => {
writer.tr_end();
}
BlockEvent::TableCellStart { alignment } => {
if *in_table_head {
writer.th_start(*alignment);
} else {
writer.td_start(*alignment);
}
cell_state.start();
}
BlockEvent::TableCellEnd => {
let content = cell_state.finish();
if !content.is_empty() {
render_inline_content(
content,
writer,
inline_parser,
inline_events,
link_refs,
footnote_store,
footnote_numbers,
options,
);
}
if *in_table_head {
writer.th_end();
} else {
writer.td_end();
}
}
}
}
}
#[inline]
fn emit_pending_task_checkbox(pending_task: &mut block::TaskState, writer: &mut HtmlWriter) {
match *pending_task {
block::TaskState::Unchecked => {
writer.write_bytes(b"<input type=\"checkbox\" disabled=\"\" /> ");
}
block::TaskState::Checked => {
writer.write_bytes(b"<input type=\"checkbox\" checked=\"\" disabled=\"\" /> ");
}
block::TaskState::None => {}
}
*pending_task = block::TaskState::None;
}
fn write_footnote_backref(writer: &mut HtmlWriter, label: &str, number: usize) {
writer.write_str(" <a href=\"#user-content-fnref-");
writer.write_string(label);
writer.write_str("\" class=\"data-footnote-backref\" aria-label=\"Back to reference ");
writer.write_string(&number.to_string());
writer.write_str("\">↩</a>");
}
struct ImageState {
title_range: Option<Range>,
title_bytes: Option<Vec<u8>>,
depth: u32,
}
struct FootnoteNumbers {
order: Vec<usize>,
ordinals: Vec<usize>,
}
impl FootnoteNumbers {
fn new(definition_count: usize) -> Self {
Self {
order: Vec::new(),
ordinals: vec![0; definition_count],
}
}
fn number(&mut self, definition_index: usize) -> Option<usize> {
let ordinal = self.ordinals.get_mut(definition_index)?;
if *ordinal == 0 {
self.order.push(definition_index);
*ordinal = self.order.len();
}
Some(*ordinal)
}
fn is_empty(&self) -> bool {
self.order.is_empty()
}
}
#[allow(clippy::too_many_arguments)]
fn render_inline_content(
text: &[u8],
writer: &mut HtmlWriter,
inline_parser: &mut InlineParser,
inline_events: &mut Vec<InlineEvent>,
link_refs: &LinkRefStore,
footnote_store: Option<&FootnoteStore>,
footnote_numbers: &mut FootnoteNumbers,
options: &Options,
) {
inline_events.clear();
inline_events.reserve((text.len() / 8).max(8));
let refs = options.allow_link_refs.then_some(link_refs);
inline_parser.parse_with_options(
text,
refs,
options.allow_html,
options.strikethrough,
options.highlight,
options.superscript,
options.subscript,
options.autolink_literals,
options.math,
footnote_store,
inline_events,
);
#[cfg(feature = "profiling")]
profiling::record_inline_events(inline_events, inline_events.capacity());
let mut image_state = None;
for event in inline_events.iter() {
render_inline_event(
text,
event,
writer,
&mut image_state,
link_refs,
options.disallowed_raw_html,
options.render_policy,
footnote_store,
footnote_numbers,
);
}
}
#[allow(clippy::too_many_arguments)]
fn render_inline_event(
text: &[u8],
event: &InlineEvent,
writer: &mut HtmlWriter,
image_state: &mut Option<ImageState>,
link_refs: &LinkRefStore,
filter_html: bool,
render_policy: RenderPolicy,
footnote_store: Option<&FootnoteStore>,
footnote_numbers: &mut FootnoteNumbers,
) {
let in_image = image_state.as_ref().is_some_and(|s| s.depth > 0);
match event {
InlineEvent::Text(range) => {
if in_image {
writer.write_escaped_attr(range.slice(text));
} else {
writer.write_text_with_entities(range.slice(text));
}
}
InlineEvent::Code(range) => {
if in_image {
let code_content = range.slice(text);
for &b in code_content {
if b == b'\n' {
writer.write_str(" ");
} else if b == b'<' {
writer.write_str("<");
} else if b == b'>' {
writer.write_str(">");
} else if b == b'&' {
writer.write_str("&");
} else if b == b'"' {
writer.write_str(""");
} else {
writer.buffer_mut().push(b);
}
}
} else {
writer.write_str("<code>");
let code_content = range.slice(text);
for &b in code_content {
if b == b'\n' {
writer.write_str(" ");
} else if b == b'<' {
writer.write_str("<");
} else if b == b'>' {
writer.write_str(">");
} else if b == b'&' {
writer.write_str("&");
} else if b == b'"' {
writer.write_str(""");
} else {
writer.buffer_mut().push(b);
}
}
writer.write_str("</code>");
}
}
InlineEvent::EmphasisStart => {
if !in_image {
writer.write_str("<em>");
}
}
InlineEvent::EmphasisEnd => {
if !in_image {
writer.write_str("</em>");
}
}
InlineEvent::StrongStart => {
if !in_image {
writer.write_str("<strong>");
}
}
InlineEvent::StrongEnd => {
if !in_image {
writer.write_str("</strong>");
}
}
InlineEvent::StrikethroughStart => {
if !in_image {
writer.write_str("<del>");
}
}
InlineEvent::StrikethroughEnd => {
if !in_image {
writer.write_str("</del>");
}
}
InlineEvent::SubscriptStart => {
if !in_image {
writer.write_str("<sub>");
}
}
InlineEvent::SubscriptEnd => {
if !in_image {
writer.write_str("</sub>");
}
}
InlineEvent::SuperscriptStart => {
if !in_image {
writer.write_str("<sup>");
}
}
InlineEvent::SuperscriptEnd => {
if !in_image {
writer.write_str("</sup>");
}
}
InlineEvent::HighlightStart => {
if !in_image {
writer.write_str("<mark>");
}
}
InlineEvent::HighlightEnd => {
if !in_image {
writer.write_str("</mark>");
}
}
InlineEvent::LinkStart { url, title } => {
if !in_image {
writer.write_str("<a href=\"");
writer.write_link_url_with_policy(url.slice(text), render_policy);
writer.write_str("\"");
if let Some(t) = title {
writer.write_str(" title=\"");
writer.write_link_title(t.slice(text));
writer.write_str("\"");
}
writer.write_str(">");
}
}
InlineEvent::LinkStartRef { def_index } => {
if !in_image {
if let Some(def) = link_refs.get(*def_index as usize) {
writer.write_str("<a href=\"");
writer.write_link_url_with_policy(&def.url, render_policy);
writer.write_str("\"");
if let Some(title) = &def.title {
writer.write_str(" title=\"");
writer.write_link_title(title);
writer.write_str("\"");
}
writer.write_str(">");
}
}
}
InlineEvent::LinkEnd => {
if !in_image {
writer.write_str("</a>");
}
}
InlineEvent::ImageStart { url, title } => {
if let Some(state) = image_state.as_mut() {
state.depth += 1;
} else {
writer.write_str("<img src=\"");
writer.write_link_url_with_policy(url.slice(text), render_policy);
writer.write_str("\" alt=\"");
*image_state = Some(ImageState {
title_range: *title,
title_bytes: None,
depth: 1,
});
}
}
InlineEvent::ImageStartRef { def_index } => {
if let Some(state) = image_state.as_mut() {
state.depth += 1;
} else if let Some(def) = link_refs.get(*def_index as usize) {
writer.write_str("<img src=\"");
writer.write_link_url_with_policy(&def.url, render_policy);
writer.write_str("\" alt=\"");
*image_state = Some(ImageState {
title_range: None,
title_bytes: def.title.clone(),
depth: 1,
});
}
}
InlineEvent::ImageEnd => {
if let Some(state) = image_state.as_mut() {
state.depth -= 1;
if state.depth == 0 {
writer.write_str("\"");
let title_range = state.title_range;
let title_bytes = state.title_bytes.clone();
*image_state = None;
if let Some(bytes) = title_bytes {
writer.write_str(" title=\"");
writer.write_link_title(&bytes);
writer.write_str("\"");
} else if let Some(title_range) = title_range {
writer.write_str(" title=\"");
writer.write_link_title(title_range.slice(text));
writer.write_str("\"");
}
writer.write_str(" />");
}
}
}
InlineEvent::AutolinkLiteral { url, kind } => {
use crate::inline::AutolinkLiteralKind;
if in_image {
writer.write_escaped_attr(url.slice(text));
} else {
writer.write_str("<a href=\"");
match kind {
AutolinkLiteralKind::Url => {
writer.write_link_url_with_policy(url.slice(text), render_policy);
}
AutolinkLiteralKind::Www => {
writer.write_str("http://");
writer.write_link_url(url.slice(text));
}
AutolinkLiteralKind::Email => {
writer.write_str("mailto:");
writer.write_link_url(url.slice(text));
}
}
writer.write_str("\">");
writer.write_escaped_text(url.slice(text));
writer.write_str("</a>");
}
}
InlineEvent::Autolink { url, is_email } => {
if in_image {
writer.write_escaped_attr(url.slice(text));
} else {
writer.write_str("<a href=\"");
if *is_email {
writer.write_str("mailto:");
writer.write_url_encoded(url.slice(text));
} else {
writer.write_url_encoded_with_policy(url.slice(text), render_policy);
}
writer.write_str("\">");
writer.write_escaped_text(url.slice(text));
writer.write_str("</a>");
}
}
InlineEvent::Html(range) => {
if in_image {
writer.write_escaped_attr(range.slice(text));
} else if render_policy == RenderPolicy::Untrusted {
writer.write_escaped_text(range.slice(text));
} else if filter_html {
writer.write_html_filtered(range.slice(text));
} else {
writer.write_bytes(range.slice(text));
}
}
InlineEvent::SoftBreak => {
if in_image {
writer.write_str(" ");
} else {
writer.write_str("\n");
}
}
InlineEvent::HardBreak => {
if in_image {
writer.write_str(" ");
} else {
writer.write_str("<br />\n");
}
}
InlineEvent::EscapedChar(ch) => {
let bytes = [*ch];
if in_image {
writer.write_escaped_attr(&bytes);
} else {
writer.write_escaped_text(&bytes);
}
}
InlineEvent::FootnoteRef { def_index } => {
if !in_image {
if let Some(fn_store) = footnote_store {
let def_idx = *def_index as usize;
if let (Some(number), Some(def)) =
(footnote_numbers.number(def_idx), fn_store.get(def_idx))
{
writer.write_str("<sup><a href=\"#user-content-fn-");
writer.write_string(&def.label);
writer.write_str("\" id=\"user-content-fnref-");
writer.write_string(&def.label);
writer.write_str("\" data-footnote-ref>");
let num_str = number.to_string();
writer.write_string(&num_str);
writer.write_str("</a></sup>");
}
}
}
}
InlineEvent::MathInline(range) => {
if in_image {
writer.write_escaped_attr(range.slice(text));
} else {
writer.write_str("<code class=\"language-math math-inline\">");
let content = range.slice(text);
for &b in content {
if b == b'\n' {
writer.write_str(" ");
} else if b == b'<' {
writer.write_str("<");
} else if b == b'>' {
writer.write_str(">");
} else if b == b'&' {
writer.write_str("&");
} else if b == b'"' {
writer.write_str(""");
} else {
writer.buffer_mut().push(b);
}
}
writer.write_str("</code>");
}
}
InlineEvent::MathDisplay(range) => {
if in_image {
writer.write_escaped_attr(range.slice(text));
} else {
writer.write_str("<code class=\"language-math math-display\">");
let content = range.slice(text);
for &b in content {
if b == b'\n' {
writer.write_str(" ");
} else if b == b'<' {
writer.write_str("<");
} else if b == b'>' {
writer.write_str(">");
} else if b == b'&' {
writer.write_str("&");
} else if b == b'"' {
writer.write_str(""");
} else {
writer.buffer_mut().push(b);
}
}
writer.write_str("</code>");
}
}
}
}
impl<R: FencedCodeRenderer + ?Sized> RenderContext<'_, '_, R> {
fn render_footnote_section(&mut self, input: &[u8]) {
let Some(footnote_store) = self.footnote_store else {
return;
};
let order = self.footnote_numbers.order.clone();
self.writer
.write_str("<section data-footnotes class=\"footnotes\">\n<ol>\n");
for (seq_num, def_idx) in order.into_iter().enumerate() {
let Some(def) = footnote_store.get(def_idx) else {
continue;
};
let number = seq_num + 1;
self.writer.write_str("<li id=\"user-content-fn-");
self.writer.write_string(&def.label);
self.writer.write_str("\">\n");
let last_paragraph_end = def
.events
.iter()
.rposition(|event| matches!(event, BlockEvent::ParagraphEnd));
let renderer = self.fenced_code_renderer.as_deref_mut();
let mut nested = RenderContext::new(
&mut *self.writer,
self.link_refs,
Some(footnote_store),
self.options,
renderer,
);
for (index, event) in def.events.iter().enumerate() {
if Some(index) == last_paragraph_end {
nested.pending_footnote_backref = Some((def.label.clone(), number));
}
nested.render_block_event(input, event);
}
self.writer.write_str("</li>\n");
}
self.writer.write_str("</ol>\n</section>\n");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn footnote_numbers_assign_constant_time_stable_ordinals() {
let mut numbers = FootnoteNumbers::new(4);
assert_eq!(numbers.number(2), Some(1));
assert_eq!(numbers.number(0), Some(2));
assert_eq!(numbers.number(2), Some(1));
assert_eq!(numbers.number(3), Some(3));
assert_eq!(numbers.number(4), None);
assert_eq!(numbers.order, vec![2, 0, 3]);
}
#[test]
fn test_basic_paragraph() {
let html = to_html("Hello, world!");
assert_eq!(html, "<p>Hello, world!</p>\n");
}
#[test]
fn test_paragraph_escaping() {
let html = to_html("<script>alert('xss')</script>");
assert_eq!(html, "<script>alert('xss')</script>");
}
#[test]
fn test_heading_h1() {
let html = to_html("# Hello");
assert!(html.contains("Hello</h1>"));
}
#[test]
fn test_heading_h2() {
let html = to_html("## World");
assert!(html.contains("World</h2>"));
}
#[test]
fn test_heading_all_levels() {
for level in 1..=6 {
let input = format!("{} Heading", "#".repeat(level));
let html = to_html(&input);
assert!(
html.contains(&format!("Heading</h{level}>")),
"Failed for level {level}: {html}"
);
}
}
#[test]
fn test_thematic_break() {
let html = to_html("---");
assert_eq!(html, "<hr />\n");
}
#[test]
fn test_thematic_break_variants() {
assert_eq!(to_html("---"), "<hr />\n");
assert_eq!(to_html("***"), "<hr />\n");
assert_eq!(to_html("___"), "<hr />\n");
assert_eq!(to_html("- - -"), "<hr />\n");
assert_eq!(to_html("----------"), "<hr />\n");
}
#[test]
fn test_multiple_paragraphs() {
let html = to_html("First\n\nSecond");
assert!(html.contains("<p>First</p>"));
assert!(html.contains("<p>Second</p>"));
}
#[test]
fn test_heading_and_paragraph() {
let html = to_html("# Title\n\nContent here.");
assert!(html.contains("Title</h1>"));
assert!(html.contains("<p>Content here.</p>"));
}
#[test]
fn test_heading_with_closing_hashes() {
let html = to_html("# Hello #");
assert!(html.contains("Hello</h1>"));
}
#[test]
fn test_complex_document() {
let input = r#"# Main Title
This is the first paragraph.
## Section 1
More content here.
---
## Section 2
Final paragraph."#;
let html = to_html(input);
assert!(html.contains("Main Title</h1>"));
assert!(html.contains("Section 1</h2>"));
assert!(html.contains("Section 2</h2>"));
assert!(html.contains("<hr />"));
assert!(html.contains("<p>This is the first paragraph.</p>"));
}
#[test]
fn test_multiline_paragraph() {
let html = to_html("Line 1\nLine 2\nLine 3");
assert!(html.starts_with("<p>"));
assert!(html.contains("Line 1"));
assert!(html.contains("Line 2"));
assert!(html.contains("Line 3"));
assert!(html.ends_with("</p>\n"));
}
#[test]
fn test_empty_input() {
let html = to_html("");
assert_eq!(html, "");
}
#[test]
fn test_only_whitespace() {
let html = to_html(" \n\n ");
assert_eq!(html, "");
}
#[test]
fn test_to_html_into() {
let mut buffer = Vec::new();
to_html_into("# Test", &mut buffer);
let html = String::from_utf8(buffer).unwrap();
assert!(html.contains("Test</h1>"));
}
#[test]
fn test_code_block_basic() {
let html = to_html("```\ncode\n```");
assert!(html.contains("<pre><code>"));
assert!(html.contains("code"));
assert!(html.contains("</code></pre>"));
}
#[test]
fn test_code_block_with_language() {
let html = to_html("```rust\nfn main() {}\n```");
assert!(html.contains("<pre><code class=\"language-rust\">"));
assert!(html.contains("fn main() {}"));
}
#[test]
fn test_code_block_escapes_html() {
let html = to_html("```\n<script>alert('xss')</script>\n```");
assert!(html.contains("<script>"));
assert!(!html.contains("<script>"));
}
#[test]
fn test_code_block_multiline() {
let html = to_html("```\nline1\nline2\n```");
assert!(html.contains("line1"));
assert!(html.contains("line2"));
}
#[test]
fn test_code_block_in_document() {
let input = r#"# Title
Some text.
```python
print("hello")
```
More text."#;
let html = to_html(input);
assert!(html.contains("Title</h1>"));
assert!(html.contains("<p>Some text.</p>"));
assert!(html.contains("<pre><code class=\"language-python\">"));
assert!(html.contains("print"));
assert!(html.contains("<p>More text.</p>"));
}
#[test]
fn test_tight_list_unordered() {
let html = to_html("- foo\n- bar\n- baz");
assert!(html.contains("<li>foo</li>"));
assert!(html.contains("<li>bar</li>"));
assert!(html.contains("<li>baz</li>"));
assert!(!html.contains("<li><p>"));
}
#[test]
fn test_loose_list_unordered() {
let html = to_html("- foo\n\n- bar\n\n- baz");
assert!(html.contains("<li>\n<p>foo</p>"));
assert!(html.contains("<li>\n<p>bar</p>"));
assert!(html.contains("<li>\n<p>baz</p>"));
}
#[test]
fn test_tight_list_ordered() {
let html = to_html("1. first\n2. second\n3. third");
assert!(html.contains("<li>first</li>"));
assert!(html.contains("<li>second</li>"));
assert!(html.contains("<li>third</li>"));
assert!(!html.contains("<li><p>"));
}
#[test]
fn test_loose_list_ordered() {
let html = to_html("1. first\n\n2. second");
assert!(html.contains("<li>\n<p>first</p>"));
assert!(html.contains("<li>\n<p>second</p>"));
}
#[test]
fn test_image_basic() {
let html = to_html("");
assert!(html.contains("<img src=\"image.png\""), "Missing img src");
assert!(html.contains("alt=\"alt\""), "Missing alt attribute");
assert!(!html.contains("!<img"), "Stray ! before img tag");
}
#[test]
fn test_image_with_title() {
let html = to_html("");
assert!(html.contains("<img"), "No img tag found");
assert!(html.contains("title=\"title\""), "Missing title attribute");
assert!(!html.contains("!<img"), "Stray ! before img tag");
}
#[test]
fn test_image_in_text() {
let html = to_html("text before  text after");
assert!(html.contains("text before"));
assert!(html.contains("<img src=\"url\""));
assert!(html.contains("text after"));
}
#[test]
fn test_image_with_nested_emphasis() {
let html = to_html("");
assert!(
html.contains("alt=\"foo bar\""),
"Alt text should be plain: {html}"
);
assert!(!html.contains("<em>"), "No <em> tags in alt text");
}
#[test]
fn test_image_with_nested_strong() {
let html = to_html("");
assert!(
html.contains("alt=\"foo bar\""),
"Alt text should be plain: {html}"
);
assert!(!html.contains("<strong>"), "No <strong> tags in alt text");
}
}
#[cfg(test)]
mod entity_tests {
#[test]
fn test_html_escape_entities() {
use html_escape::decode_html_entities;
assert_eq!(decode_html_entities("ä").as_ref(), "ä");
assert_eq!(decode_html_entities("ä").as_ref(), "ä");
assert_eq!(decode_html_entities("ä").as_ref(), "ä");
assert_eq!(decode_html_entities("&").as_ref(), "&");
assert_eq!(decode_html_entities("foo%20bä").as_ref(), "foo%20bä");
}
}