Compare commits

..

8 Commits

Author SHA1 Message Date
af63f069ab Merge pull request 'version 0.1.3' (#4) from dev into master
Reviewed-on: #4
2025-12-26 17:24:13 +00:00
99b1205cec version 0.1.3
All checks were successful
Test the running changes / Test (push) Successful in 34s
2025-12-26 19:23:38 +02:00
47cb8d2cc2 add tests for html generation, refactor lists 2025-12-26 19:22:59 +02:00
e04beb51ee Merge pull request 'version 0.1.2' (#3) from dev into master
Reviewed-on: #3
2025-12-26 14:40:57 +00:00
b7ca4ac6e3 version 0.1.2
All checks were successful
Test the running changes / Test (push) Successful in 39s
2025-12-26 16:39:10 +02:00
ba598f7e34 removed broken release workflow 2025-12-26 16:38:14 +02:00
34304546ad added support for parsing lists
All checks were successful
Test the running changes / Test (push) Successful in 32s
2025-12-26 16:36:57 +02:00
03b5360ec5 fixed weird block parsing issues
All checks were successful
Test the running changes / Test (push) Successful in 35s
2025-12-24 23:26:09 +02:00
12 changed files with 436 additions and 27 deletions

2
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]] [[package]]
name = "marginal" name = "marginal"
version = "0.1.0" version = "0.1.2"
dependencies = [ dependencies = [
"nom", "nom",
] ]

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "marginal" name = "marginal"
version = "0.1.0" version = "0.1.3"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]

View File

@@ -66,6 +66,7 @@ command = [
"cargo", "nextest", "run", "cargo", "nextest", "run",
"--hide-progress-bar", "--hide-progress-bar",
"--failure-output", "final", "--failure-output", "final",
"--no-fail-fast",
] ]
need_stdout = true need_stdout = true
analyzer = "nextest" analyzer = "nextest"

View File

@@ -31,4 +31,6 @@ pub enum Block {
Code { content: String, lang: String }, Code { content: String, lang: String },
Quote { inner: Box<Block> }, Quote { inner: Box<Block> },
Paragraph { inner: Vec<Inline> }, Paragraph { inner: Vec<Inline> },
List { ordered: bool, items: Vec<Block> },
Null,
} }

View File

@@ -15,12 +15,136 @@ impl ToHtml for Vec<Block> {
impl ToHtml for Block { impl ToHtml for Block {
fn to_html(&self) -> String { fn to_html(&self) -> String {
match self { match self {
Block::Paragraph { inner } => format!("<p>{}</p><br>", inner.to_html()), Block::Paragraph { inner } => format!("<p>{}</p>", inner.to_html()),
Block::Heading { inner, level } => { Block::Heading { inner, level } => {
format!("<h{}>{}</h{}>", level, inner.to_html(), level) format!("<h{}>{}</h{}>", level, inner.to_html(), level)
} }
Block::Code { content, lang: _ } => format!("<pre><code>{content}</code></pre>"), Block::Code { content, lang: _ } => format!("<pre><code>{content}</code></pre>"),
Block::Quote { inner } => format!("<div class=\"quote\">{}</div>", inner.to_html()), Block::Quote { inner } => format!("<div class=\"quote\">{}</div>", inner.to_html()),
Block::List { ordered, items } => {
let tag = if *ordered { "ol" } else { "ul" };
let mut html = format!("<{}>", tag);
for item in items {
html.push_str(&format!("<li>{}</li>", &item.to_html()));
}
html.push_str(&format!("</{}>", tag));
html
}
Block::Null => "".to_string(),
} }
} }
} }
#[cfg(test)]
mod test {
use crate::{ast::*, generator::ToHtml};
#[test]
fn paragraph_to_html() {
let ast = Block::Paragraph {
inner: vec![Inline::Text {
content: "hello".to_string(),
}],
};
let html = ast.to_html();
assert_eq!(html, "<p>hello</p>".to_string());
}
#[test]
fn heading_to_html() {
let ast = Block::Heading {
inner: vec![Inline::Text {
content: "hello".to_string(),
}],
level: 2,
};
let html = ast.to_html();
assert_eq!(html, "<h2>hello</h2>".to_string());
}
#[test]
fn code_to_html() {
let ast = Block::Code {
content: "echo 'hello world!'".to_string(),
lang: "bash".to_string(),
};
let html = ast.to_html();
assert_eq!(
html,
"<pre><code>echo 'hello world!'</code></pre>".to_string()
);
}
#[test]
fn quote_to_html() {
let ast = Block::Quote {
inner: Box::new(Block::Paragraph {
inner: vec![Inline::Text {
content: "sun tzu".to_string(),
}],
}),
};
let html = ast.to_html();
assert_eq!(html, "<div class=\"quote\"><p>sun tzu</p></div>");
}
#[test]
fn ordered_list_to_html() {
let ast = Block::List {
ordered: true,
items: vec![
Block::Paragraph {
inner: vec![Inline::Text {
content: "item 1".to_string(),
}],
},
Block::Paragraph {
inner: vec![Inline::Text {
content: "item 2".to_string(),
}],
},
],
};
let html = ast.to_html();
assert_eq!(
html,
"<ol><li><p>item 1</p></li><li><p>item 2</p></li></ol>".to_string()
);
}
#[test]
fn unordered_list_to_html() {
let ast = Block::List {
ordered: false,
items: vec![
Block::Paragraph {
inner: vec![Inline::Text {
content: "item 1".to_string(),
}],
},
Block::Paragraph {
inner: vec![Inline::Text {
content: "item 2".to_string(),
}],
},
],
};
let html = ast.to_html();
assert_eq!(
html,
"<ul><li><p>item 1</p></li><li><p>item 2</p></li></ul>".to_string()
);
}
}

View File

@@ -7,7 +7,15 @@ use super::{GenerationError, ToHtml, TryToHtml};
impl TryToHtml for PathBuf { impl TryToHtml for PathBuf {
fn try_to_html(&self) -> Result<String, GenerationError> { fn try_to_html(&self) -> Result<String, GenerationError> {
let content_md = fs::read_to_string(self)?; let content_md = fs::read_to_string(self)?;
let (_rem, generated) = blocks(&content_md)?; let (rem, generated) = blocks(&content_md)?;
if !rem.is_empty() {
Err(GenerationError::Termination {
file: self.to_owned(),
remainder: rem.to_string(),
})
} else {
Ok(generated.to_html()) Ok(generated.to_html())
} }
} }
}

View File

@@ -25,3 +25,74 @@ impl ToHtml for Inline {
} }
} }
} }
#[cfg(test)]
mod test {
use super::*;
#[test]
fn text_to_html() {
let ast = Inline::Text {
content: "hello".to_string(),
};
let html = ast.to_html();
assert_eq!(html, "hello".to_string());
}
#[test]
fn bold_to_html() {
let ast = Inline::Bold {
inner: vec![Inline::Text {
content: "hello".to_string(),
}],
};
let html = ast.to_html();
assert_eq!(html, "<b>hello</b>".to_string());
}
#[test]
fn italic_to_html() {
let ast = Inline::Italic {
inner: vec![Inline::Text {
content: "hello".to_string(),
}],
};
let html = ast.to_html();
assert_eq!(html, "<i>hello</i>".to_string());
}
#[test]
fn code_to_html() {
let ast = Inline::Code {
content: "echo 'hello'".to_string(),
};
let html = ast.to_html();
assert_eq!(html, "<code>echo 'hello'</code>".to_string());
}
#[test]
fn link_to_html() {
let ast = Inline::Link {
inner: vec![Inline::Text {
content: "my webpage".to_string(),
}],
href: crate::ast::Href("https://jlux.dev".to_string()),
};
let html = ast.to_html();
assert_eq!(
html,
"<a href=\"https://jlux.dev\">my webpage</a>".to_string()
);
//format!("<a href=\"{}\">{}</a>", href, inner.to_html())
}
}

View File

@@ -1,4 +1,4 @@
use std::fmt::Display; use std::{fmt::Display, path::PathBuf};
use crate::parser::MarkdownParseError; use crate::parser::MarkdownParseError;
@@ -10,6 +10,7 @@ pub mod inline;
pub enum GenerationError { pub enum GenerationError {
IO(std::io::Error), IO(std::io::Error),
Parse(nom::Err<MarkdownParseError>), Parse(nom::Err<MarkdownParseError>),
Termination { file: PathBuf, remainder: String },
} }
impl Display for GenerationError { impl Display for GenerationError {
@@ -18,8 +19,13 @@ impl Display for GenerationError {
f, f,
"Generation Error: {}", "Generation Error: {}",
match self { match self {
GenerationError::IO(e) => format!("IO: {e}"), GenerationError::IO(e) => format!("IO error: {e}"),
GenerationError::Parse(e) => format!("Parse: {e}"), GenerationError::Parse(e) => format!("Parse error: {e}"),
GenerationError::Termination { file, remainder } => format!(
"Termination error at `{}` before:\n{}",
file.display(),
remainder
),
} }
) )
} }

View File

@@ -3,6 +3,7 @@ use nom::{
IResult, Parser, IResult, Parser,
branch::alt, branch::alt,
bytes::complete::{tag, take_until}, bytes::complete::{tag, take_until},
combinator::peek,
multi::{many_m_n, many0, many1}, multi::{many_m_n, many0, many1},
sequence::{delimited, terminated}, sequence::{delimited, terminated},
}; };
@@ -15,12 +16,24 @@ pub fn blocks(input: &str) -> IResult<&str, Vec<Block>, MarkdownParseError> {
pub fn block(input: &str) -> IResult<&str, Block, MarkdownParseError> { pub fn block(input: &str) -> IResult<&str, Block, MarkdownParseError> {
terminated( terminated(
alt((heading_block, code_block, quote_block, paragraph_block)), alt((
heading_block,
ordered_list,
unordered_list,
code_block,
quote_block,
paragraph_block,
empty_line,
)),
tag("\n"), tag("\n"),
) )
.parse(input) .parse(input)
} }
fn empty_line(input: &str) -> IResult<&str, Block, MarkdownParseError> {
tag("").parse(input).map(|(rem, _)| (rem, Block::Null))
}
fn paragraph_block(input: &str) -> IResult<&str, Block, MarkdownParseError> { fn paragraph_block(input: &str) -> IResult<&str, Block, MarkdownParseError> {
(inline) (inline)
.parse(input) .parse(input)
@@ -45,7 +58,7 @@ fn code_block(input: &str) -> IResult<&str, Block, MarkdownParseError> {
delimited( delimited(
tag("```"), tag("```"),
(take_until("\n"), tag("\n"), take_until("```\n")), (take_until("\n"), tag("\n"), take_until("```\n")),
tag("```\n"), (tag("```"), peek(tag("\n"))),
) )
.parse(input) .parse(input)
.map(|(rem, (lang, _, code))| { .map(|(rem, (lang, _, code))| {
@@ -72,6 +85,32 @@ fn quote_block(input: &str) -> IResult<&str, Block, MarkdownParseError> {
}) })
} }
fn unordered_list(input: &str) -> IResult<&str, Block, MarkdownParseError> {
many1((tag("- "), block)).parse(input).map(|(rem, v)| {
let items = v.into_iter().map(|(_, b)| b).collect();
(
rem,
Block::List {
ordered: false,
items,
},
)
})
}
fn ordered_list(input: &str) -> IResult<&str, Block, MarkdownParseError> {
many1((tag("1. "), block)).parse(input).map(|(rem, v)| {
let items = v.into_iter().map(|(_, b)| b).collect();
(
rem,
Block::List {
ordered: true,
items,
},
)
})
}
//|-------------------------------------------------------------------------------| //|-------------------------------------------------------------------------------|
//| TESTS TESTS TESTS TESTS TESTS TESTS TESTS TESTS TESTS TESTS TESTS TESTS TESTS | //| TESTS TESTS TESTS TESTS TESTS TESTS TESTS TESTS TESTS TESTS TESTS TESTS TESTS |
//|-------------------------------------------------------------------------------| //|-------------------------------------------------------------------------------|
@@ -83,10 +122,10 @@ mod test {
#[test] #[test]
fn single_paragraph() { fn single_paragraph() {
let md = "Hello markdown!!"; let md = "Hello markdown!!\n";
let (rem, block) = paragraph_block(md).unwrap(); let (rem, block) = paragraph_block(md).unwrap();
assert_eq!(rem, ""); assert_eq!(rem, "\n");
assert_eq!( assert_eq!(
block, block,
Block::Paragraph { Block::Paragraph {
@@ -107,7 +146,7 @@ fn main() {
"; ";
let (rem, block) = code_block(md).unwrap(); let (rem, block) = code_block(md).unwrap();
assert_eq!(rem, ""); assert_eq!(rem, "\n");
assert_eq!( assert_eq!(
block, block,
Block::Code { Block::Code {
@@ -125,7 +164,7 @@ echo \"hello world\"
"; ";
let (rem, block) = code_block(md).unwrap(); let (rem, block) = code_block(md).unwrap();
assert_eq!(rem, ""); assert_eq!(rem, "\n");
assert_eq!( assert_eq!(
block, block,
Block::Code { Block::Code {
@@ -145,11 +184,11 @@ echo hello
} }
#[test] #[test]
fn level_1_heading() { fn level_2_heading() {
let md = "## Heading2"; let md = "## Heading2\n";
let (rem, block) = heading_block(md).unwrap(); let (rem, block) = heading_block(md).unwrap();
assert_eq!(rem, ""); assert_eq!(rem, "\n");
assert_eq!( assert_eq!(
block, block,
Block::Heading { Block::Heading {
@@ -163,16 +202,16 @@ echo hello
#[test] #[test]
fn heading_no_space() { fn heading_no_space() {
let md = "#heading"; let md = "#heading\n";
assert!(heading_block(md).is_err()); assert!(heading_block(md).is_err());
} }
#[test] #[test]
fn level_6_heading() { fn level_6_heading() {
let md = "###### Heading6"; let md = "###### Heading6\n";
let (rem, block) = heading_block(md).unwrap(); let (rem, block) = heading_block(md).unwrap();
assert_eq!(rem, ""); assert_eq!(rem, "\n");
assert_eq!( assert_eq!(
block, block,
Block::Heading { Block::Heading {
@@ -186,7 +225,7 @@ echo hello
#[test] #[test]
fn no_level_7_heading() { fn no_level_7_heading() {
let md = "####### Heading7"; let md = "####### Heading7\n";
assert!(heading_block(md).is_err()); assert!(heading_block(md).is_err());
} }
@@ -233,4 +272,155 @@ Hello MD
] ]
); );
} }
#[test]
fn simple_unordered_list() {
let md = "- a\n- b\n- c\n- b again with some `code`\n\n";
let (rem, block) = unordered_list(md).unwrap();
assert_eq!(rem, "\n");
assert_eq!(
block,
Block::List {
ordered: false,
items: vec![
Block::Paragraph {
inner: vec![Inline::Text {
content: "a".to_string()
}]
},
Block::Paragraph {
inner: vec![Inline::Text {
content: "b".to_string()
}]
},
Block::Paragraph {
inner: vec![Inline::Text {
content: "c".to_string()
}]
},
Block::Paragraph {
inner: vec![
Inline::Text {
content: "b again with some ".to_string()
},
Inline::Code {
content: "code".to_string()
}
]
},
]
}
);
}
#[test]
fn simple_ordered_list() {
let md = "1. a\n1. b\n1. c\n1. b again with some `code`\n\n";
let (rem, block) = ordered_list(md).unwrap();
assert_eq!(rem, "\n");
assert_eq!(
block,
Block::List {
ordered: true,
items: vec![
Block::Paragraph {
inner: vec![Inline::Text {
content: "a".to_string()
}]
},
Block::Paragraph {
inner: vec![Inline::Text {
content: "b".to_string()
}]
},
Block::Paragraph {
inner: vec![Inline::Text {
content: "c".to_string()
}]
},
Block::Paragraph {
inner: vec![
Inline::Text {
content: "b again with some ".to_string()
},
Inline::Code {
content: "code".to_string()
}
]
},
]
}
);
}
#[test]
fn empty_block() {
let md = "\n";
let (rem, ast) = block(md).unwrap();
assert_eq!(rem, "");
assert_eq!(ast, Block::Null);
}
#[test]
fn complex_1() {
let md = "
# hello
## second header
blablabla
1. hahaha
1. second
";
let (rem, ast) = blocks(md).unwrap();
assert_eq!(rem, "");
assert_eq!(
ast,
vec![
Block::Null,
Block::Heading {
inner: vec![Inline::Text {
content: "hello".to_string()
}],
level: 1
},
Block::Null,
Block::Heading {
inner: vec![Inline::Text {
content: "second header".to_string()
}],
level: 2
},
Block::Null,
Block::Paragraph {
inner: vec![Inline::Text {
content: "blablabla".to_string()
}]
},
Block::Null,
Block::List {
ordered: true,
items: vec![
Block::Paragraph {
inner: vec![Inline::Text {
content: "hahaha".to_string()
}]
},
Block::Paragraph {
inner: vec![Inline::Text {
content: "second".to_string()
}]
},
]
},
]
);
}
} }

View File

@@ -3,7 +3,7 @@ use nom::{
Parser, Parser,
branch::alt, branch::alt,
bytes::complete::{is_not, tag}, bytes::complete::{is_not, tag},
multi::many0, multi::many1,
sequence::delimited, sequence::delimited,
}; };
@@ -12,7 +12,7 @@ use crate::ast::{Href, Inline};
use super::MarkdownParseError; use super::MarkdownParseError;
pub fn inline(input: &str) -> IResult<&str, Vec<Inline>, MarkdownParseError> { pub fn inline(input: &str) -> IResult<&str, Vec<Inline>, MarkdownParseError> {
many0(alt(( many1(alt((
text_inline, text_inline,
bold_inline, bold_inline,
italic_inline, italic_inline,
@@ -85,10 +85,10 @@ mod test {
#[test] #[test]
fn single_text() { fn single_text() {
let md = "hello normal inline"; let md = "hello normal inline\n";
let (rem, parsed) = text_inline(md).unwrap(); let (rem, parsed) = text_inline(md).unwrap();
assert_eq!(rem, ""); assert_eq!(rem, "\n");
assert_eq!( assert_eq!(
parsed, parsed,
Inline::Text { Inline::Text {
@@ -184,7 +184,7 @@ mod test {
#[test] #[test]
fn normal_and_nested_bold() { fn normal_and_nested_bold() {
let md = "some **extra* bold* stuff"; let md = "some *very *extra* bold* stuff";
let (rem, parsed) = inline(md).unwrap(); let (rem, parsed) = inline(md).unwrap();
assert_eq!(rem, ""); assert_eq!(rem, "");
@@ -194,7 +194,11 @@ mod test {
Inline::Text { Inline::Text {
content: "some ".to_string() content: "some ".to_string()
}, },
Inline::Bold { inner: vec![] }, Inline::Bold {
inner: vec![Inline::Text {
content: "very ".to_string()
}]
},
Inline::Text { Inline::Text {
content: "extra".to_string() content: "extra".to_string()
}, },

View File

@@ -1,3 +1,6 @@
//! A weird markdown parser. Please don't forget to add a newline in the end of a file or it won't
//! work :)
use std::fmt::{Debug, Display}; use std::fmt::{Debug, Display};
pub mod block; pub mod block;