Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/markdown/elements.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use super::text_style::{Color, TextStyle, UndefinedPaletteColorError};
use crate::theme::{ColorPalette, raw::RawColor};
use crate::{
markdown::html::block::HtmlBlock,
theme::{ColorPalette, raw::RawColor},
};
use comrak::nodes::AlertType;
use std::{fmt, iter, path::PathBuf, str::FromStr};
use unicode_width::UnicodeWidthStr;
Expand Down Expand Up @@ -48,8 +51,8 @@ pub(crate) enum MarkdownElement {
/// A thematic break.
ThematicBreak,

/// An HTML comment.
Comment { comment: String, source_position: SourcePosition },
/// An HTML block.
HtmlBlock { block: HtmlBlock, source_position: SourcePosition },

/// A block quote containing a list of lines.
BlockQuote(Vec<Line<RawColor>>),
Expand Down
100 changes: 100 additions & 0 deletions src/markdown/html/block.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
use tl::Node;

pub(crate) struct HtmlBlockParser;

impl HtmlBlockParser {
pub(crate) fn parse(&self, input: &str) -> Result<Vec<HtmlBlock>, ParseHtmlBlockError> {
let dom = tl::parse(input, Default::default())?;
let parser = dom.parser();
let children = dom.children();
let mut output = Vec::new();
for child in children {
let child = child.get(parser).expect("faild to get");
match child {
Node::Tag(tag) => match tag.name().as_bytes() {
b"speaker-note" => {
let contents = child.inner_text(parser);
let text = if let Some(text) = contents.strip_prefix('\n') { text } else { &contents };
let lines = text.lines().map(|l| l.to_string()).collect();
output.push(HtmlBlock::SpeakerNotes { lines });
}
name => return Err(ParseHtmlBlockError::UnsupportedTag(String::from_utf8_lossy(name).to_string())),
},
Node::Comment(bytes) => {
let start_tag = "<!--";
let end_tag = "-->";
let text = bytes.as_utf8_str();
let text = &text[start_tag.len()..];
let text = &text[0..text.len() - end_tag.len()];
output.push(HtmlBlock::Comment(text.to_string()));
}
Node::Raw(_) => (),
};
}
Ok(output)
}
}

#[derive(Clone, Debug)]
pub(crate) enum HtmlBlock {
SpeakerNotes { lines: Vec<String> },
Comment(String),
}

#[derive(Debug, thiserror::Error)]
pub(crate) enum ParseHtmlBlockError {
#[error("parsing html failed: {0}")]
ParsingHtml(#[from] tl::ParseError),

#[error("unsupported HTML tag: {0}")]
UnsupportedTag(String),
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn speaker_note() {
let input = r"
<speaker-note>
hello

this is
text
</speaker-note>
";
let mut blocks = HtmlBlockParser.parse(&input).expect("parse failed");
assert_eq!(blocks.len(), 1);

let HtmlBlock::SpeakerNotes { lines } = blocks.pop().unwrap() else {
panic!("not a speaker note!");
};
let expected_lines = &["hello", "", "this is", "text"];
assert_eq!(lines, expected_lines);
}

#[test]
fn comment() {
let input = r"
<!-- this is a comment -->
";
let mut blocks = HtmlBlockParser.parse(&input).expect("parse failed");
assert_eq!(blocks.len(), 1);

let HtmlBlock::Comment(comment) = blocks.pop().unwrap() else {
panic!("not a comment!");
};
assert_eq!(comment, " this is a comment ");
}

#[test]
fn multiple_blocks() {
let input = r"
<speaker-note>hello</speaker-note>
<!-- note -->
";
let blocks = HtmlBlockParser.parse(&input).expect("parse failed");
assert_eq!(blocks.len(), 2);
}
}
91 changes: 49 additions & 42 deletions src/markdown/html.rs → src/markdown/html/inline.rs
Original file line number Diff line number Diff line change
@@ -1,48 +1,50 @@
use super::text_style::{Color, TextStyle};
use crate::theme::raw::{ParseColorError, RawColor};
use crate::{
markdown::text_style::{Color, TextStyle},
theme::raw::{ParseColorError, RawColor},
};
use std::{borrow::Cow, str, str::Utf8Error};
use tl::Attributes;

pub(crate) struct HtmlParseOptions {
pub(crate) struct InlineHtmlParseOptions {
pub(crate) strict: bool,
}

impl Default for HtmlParseOptions {
impl Default for InlineHtmlParseOptions {
fn default() -> Self {
Self { strict: true }
}
}

#[derive(Default)]
pub(crate) struct HtmlParser {
options: HtmlParseOptions,
pub(crate) struct InlineHtmlParser {
options: InlineHtmlParseOptions,
}

impl HtmlParser {
pub(crate) fn parse(self, input: &str) -> Result<HtmlInline, ParseHtmlError> {
impl InlineHtmlParser {
pub(crate) fn parse(self, input: &str) -> Result<HtmlInline, ParseInlineHtmlError> {
if input.starts_with("</") {
if input.starts_with("</span") {
return Ok(HtmlInline::CloseTag { tag: HtmlTag::Span });
return Ok(HtmlInline::CloseTag { tag: HtmlInlineTag::Span });
} else if input.starts_with("</sup") {
return Ok(HtmlInline::CloseTag { tag: HtmlTag::Sup });
return Ok(HtmlInline::CloseTag { tag: HtmlInlineTag::Sup });
} else {
return Err(ParseHtmlError::UnsupportedClosingTag(input.to_string()));
return Err(ParseInlineHtmlError::UnsupportedClosingTag(input.to_string()));
}
}
let dom = tl::parse(input, Default::default())?;
let top = dom.children().iter().next().ok_or(ParseHtmlError::NoTags)?;
let top = dom.children().iter().next().ok_or(ParseInlineHtmlError::NoTags)?;
let node = top.get(dom.parser()).expect("failed to get");
let tag = node.as_tag().ok_or(ParseHtmlError::NoTags)?;
let tag = node.as_tag().ok_or(ParseInlineHtmlError::NoTags)?;
let (output_tag, base_style) = match tag.name().as_bytes() {
b"span" => (HtmlTag::Span, TextStyle::default()),
b"sup" => (HtmlTag::Sup, TextStyle::default().superscript()),
_ => return Err(ParseHtmlError::UnsupportedHtml),
b"span" => (HtmlInlineTag::Span, TextStyle::default()),
b"sup" => (HtmlInlineTag::Sup, TextStyle::default().superscript()),
_ => return Err(ParseInlineHtmlError::UnsupportedHtml),
};
let style = self.parse_attributes(tag.attributes())?;
Ok(HtmlInline::OpenTag { style: style.merged(&base_style), tag: output_tag })
}

fn parse_attributes(&self, attributes: &Attributes) -> Result<TextStyle<RawColor>, ParseHtmlError> {
fn parse_attributes(&self, attributes: &Attributes) -> Result<TextStyle<RawColor>, ParseInlineHtmlError> {
let mut style = TextStyle::default();
for (name, value) in attributes.iter() {
let value = value.unwrap_or(Cow::Borrowed(""));
Expand All @@ -54,44 +56,48 @@ impl HtmlParser {
}
_ => {
if self.options.strict {
return Err(ParseHtmlError::UnsupportedTagAttribute(name.to_string()));
return Err(ParseInlineHtmlError::UnsupportedTagAttribute(name.to_string()));
}
}
}
}
Ok(style)
}

fn parse_css_attribute(&self, attribute: &str, style: &mut TextStyle<RawColor>) -> Result<(), ParseHtmlError> {
fn parse_css_attribute(
&self,
attribute: &str,
style: &mut TextStyle<RawColor>,
) -> Result<(), ParseInlineHtmlError> {
for attribute in attribute.split(';') {
let attribute = attribute.trim();
if attribute.is_empty() {
continue;
}
let (key, value) = attribute.split_once(':').ok_or(ParseHtmlError::NoColonInAttribute)?;
let (key, value) = attribute.split_once(':').ok_or(ParseInlineHtmlError::NoColonInAttribute)?;
let key = key.trim();
let value = value.trim();
match key {
"color" => style.colors.foreground = Some(Self::parse_color(value)?),
"background-color" => style.colors.background = Some(Self::parse_color(value)?),
_ => {
if self.options.strict {
return Err(ParseHtmlError::UnsupportedCssAttribute(key.into()));
return Err(ParseInlineHtmlError::UnsupportedCssAttribute(key.into()));
}
}
}
}
Ok(())
}

fn parse_color(input: &str) -> Result<RawColor, ParseHtmlError> {
fn parse_color(input: &str) -> Result<RawColor, ParseInlineHtmlError> {
if input.starts_with('#') {
let color = input.strip_prefix('#').unwrap().parse()?;
if matches!(color, RawColor::Color(Color::Rgb { .. })) { Ok(color) } else { Ok(input.parse()?) }
} else {
let color = input.parse::<RawColor>()?;
if matches!(color, RawColor::Color(Color::Rgb { .. })) {
Err(ParseHtmlError::InvalidColor("missing '#' in rgb color".into()))
Err(ParseInlineHtmlError::InvalidColor("missing '#' in rgb color".into()))
} else {
Ok(color)
}
Expand All @@ -101,18 +107,18 @@ impl HtmlParser {

#[derive(Debug, PartialEq)]
pub(crate) enum HtmlInline {
OpenTag { style: TextStyle<RawColor>, tag: HtmlTag },
CloseTag { tag: HtmlTag },
OpenTag { style: TextStyle<RawColor>, tag: HtmlInlineTag },
CloseTag { tag: HtmlInlineTag },
}

#[derive(Clone, Debug, PartialEq)]
pub(crate) enum HtmlTag {
pub(crate) enum HtmlInlineTag {
Span,
Sup,
}

#[derive(Debug, thiserror::Error)]
pub(crate) enum ParseHtmlError {
pub(crate) enum ParseInlineHtmlError {
#[error("parsing html failed: {0}")]
ParsingHtml(#[from] tl::ParseError),

Expand Down Expand Up @@ -141,7 +147,7 @@ pub(crate) enum ParseHtmlError {
UnsupportedClosingTag(String),
}

impl From<ParseColorError> for ParseHtmlError {
impl From<ParseColorError> for ParseInlineHtmlError {
fn from(e: ParseColorError) -> Self {
Self::InvalidColor(e.to_string())
}
Expand All @@ -154,23 +160,24 @@ mod tests {

#[test]
fn parse_style() {
let tag =
HtmlParser::default().parse(r#"<span style="color: red; background-color: black">"#).expect("parse failed");
let HtmlInline::OpenTag { style, tag: HtmlTag::Span } = tag else { panic!("not an open tag") };
let tag = InlineHtmlParser::default()
.parse(r#"<span style="color: red; background-color: black">"#)
.expect("parse failed");
let HtmlInline::OpenTag { style, tag: HtmlInlineTag::Span } = tag else { panic!("not an open tag") };
assert_eq!(style, TextStyle::default().bg_color(Color::Black).fg_color(Color::Red));
}

#[test]
fn parse_sup() {
let tag = HtmlParser::default().parse(r#"<sup>"#).expect("parse failed");
let HtmlInline::OpenTag { style, tag: HtmlTag::Sup } = tag else { panic!("not an open tag") };
let tag = InlineHtmlParser::default().parse(r#"<sup>"#).expect("parse failed");
let HtmlInline::OpenTag { style, tag: HtmlInlineTag::Sup } = tag else { panic!("not an open tag") };
assert_eq!(style, TextStyle::default().superscript());
}

#[test]
fn parse_class() {
let tag = HtmlParser::default().parse(r#"<span class="foo">"#).expect("parse failed");
let HtmlInline::OpenTag { style, tag: HtmlTag::Span } = tag else { panic!("not an open tag") };
let tag = InlineHtmlParser::default().parse(r#"<span class="foo">"#).expect("parse failed");
let HtmlInline::OpenTag { style, tag: HtmlInlineTag::Span } = tag else { panic!("not an open tag") };
assert_eq!(
style,
TextStyle::default()
Expand All @@ -180,10 +187,10 @@ mod tests {
}

#[rstest]
#[case::span("</span>", HtmlTag::Span)]
#[case::sup("</sup>", HtmlTag::Sup)]
fn parse_end_tag(#[case] input: &str, #[case] tag: HtmlTag) {
let inline = HtmlParser::default().parse(input).expect("parse failed");
#[case::span("</span>", HtmlInlineTag::Span)]
#[case::sup("</sup>", HtmlInlineTag::Sup)]
fn parse_end_tag(#[case] input: &str, #[case] tag: HtmlInlineTag) {
let inline = InlineHtmlParser::default().parse(input).expect("parse failed");
assert_eq!(inline, HtmlInline::CloseTag { tag });
}

Expand All @@ -194,21 +201,21 @@ mod tests {
#[case::invalid_attribute("<span style=\"bleh: 42\"")]
#[case::invalid_color("<span style=\"color: 42\"")]
fn parse_invalid_html(#[case] input: &str) {
HtmlParser::default().parse(input).expect_err("parse succeeded");
InlineHtmlParser::default().parse(input).expect_err("parse succeeded");
}

#[rstest]
#[case::rgb("#ff0000", Color::Rgb{r: 255, g: 0, b: 0})]
#[case::red("red", Color::Red)]
fn parse_color(#[case] input: &str, #[case] expected: Color) {
let color = HtmlParser::parse_color(input).expect("parse failed");
let color = InlineHtmlParser::parse_color(input).expect("parse failed");
assert_eq!(color, expected.into());
}

#[rstest]
#[case::rgb("ff0000")]
#[case::red("#red")]
fn parse_invalid_color(#[case] input: &str) {
HtmlParser::parse_color(input).expect_err("parse succeeded");
InlineHtmlParser::parse_color(input).expect_err("parse succeeded");
}
}
4 changes: 4 additions & 0 deletions src/markdown/html/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pub(crate) mod block;
pub(crate) mod inline;

pub(crate) use inline::{HtmlInline, HtmlInlineTag, InlineHtmlParser, ParseInlineHtmlError};
Loading
Loading