fluffle/src/markdown.rs

886 lines
25 KiB
Rust
Raw Normal View History

use std::collections::HashSet;
2025-07-20 18:29:43 -04:00
pub fn render_markdown(input: &str) -> String {
let html = tetratto_shared::markdown::render_markdown_dirty(&parse_page(&parse_details(
&parse_text_color(&parse_highlight(&parse_link(&parse_image(
&parse_image_size(&parse_underline(&parse_comment(
&input.replace("[/]", "<br />"),
))),
2025-07-24 01:06:03 -04:00
)))),
)))
2025-07-25 22:26:06 -04:00
.replace("$per", "%");
let mut allowed_attributes = HashSet::new();
allowed_attributes.insert("id");
allowed_attributes.insert("class");
allowed_attributes.insert("ref");
allowed_attributes.insert("aria-label");
allowed_attributes.insert("lang");
allowed_attributes.insert("title");
allowed_attributes.insert("align");
allowed_attributes.insert("src");
allowed_attributes.insert("style");
2025-07-24 01:06:03 -04:00
tetratto_shared::markdown::clean_html(
html.replace("<style>", "<span>:temp_style")
.replace("</style>", "</span>:temp_style"),
allowed_attributes,
)
.replace("<span>:temp_style", "<style>")
.replace("</span>:temp_style", "</style>")
}
2025-07-24 15:10:59 -04:00
pub(crate) fn is_numeric(value: &str) -> bool {
let mut is_numeric = false;
for char in value.chars() {
is_numeric = char.is_numeric();
}
is_numeric
}
pub(crate) fn slice(x: &str, range: core::ops::RangeFrom<usize>) -> String {
(&x.chars().collect::<Vec<char>>()[range])
.iter()
.collect::<String>()
}
fn parse_text_color_line(output: &mut String, buffer: &mut String, line: &str) {
let mut in_color_buffer = false;
let mut in_main_buffer = false;
let mut color_buffer = String::new();
let mut close_1 = false;
2025-07-24 15:10:59 -04:00
for (i, char) in line.chars().enumerate() {
if close_1 && char != '%' {
// we expected to see another percentage to close the main buffer,
// not getting that means this wasn't meant to be a color
buffer.push('%');
in_main_buffer = false;
close_1 = false;
}
match char {
'%' => {
if in_color_buffer {
in_color_buffer = false;
in_main_buffer = true;
continue;
}
if in_main_buffer {
// ending
if !close_1 {
close_1 = true;
continue;
}
// by this point, we have: !
// %color_buffer%main_buffer%%
output.push_str(&format!(
2025-07-25 15:12:15 -04:00
"<span style=\"color: {color_buffer}\" class=\"color_block\">{buffer}</span>"
));
color_buffer.clear();
buffer.clear();
// ...
in_main_buffer = false;
close_1 = false;
continue;
}
// start
2025-07-24 15:10:59 -04:00
// scan ahead
let ahead = slice(line, i..);
if !ahead.contains("%%") {
// no closing sequence, we're done
2025-07-26 00:05:13 -04:00
buffer.push(char);
2025-07-24 15:10:59 -04:00
continue;
}
// flush buffer
output.push_str(&buffer);
buffer.clear();
// toggle open
in_color_buffer = true;
}
' ' => {
if in_color_buffer == true {
buffer.push_str(&color_buffer);
color_buffer.clear();
}
buffer.push(char);
}
_ => {
if in_color_buffer {
color_buffer.push(char)
} else {
buffer.push(char)
}
}
}
}
}
fn parse_highlight_line(output: &mut String, buffer: &mut String, line: &str) {
let mut open_1 = false;
let mut open_2 = false;
let mut close_1 = false;
let mut is_open = false;
for char in line.chars() {
if close_1 && char != '=' {
buffer.push('=');
close_1 = false;
}
match char {
'=' => {
if !is_open {
// flush buffer
output.push_str(&buffer);
buffer.clear();
// toggle open
open_1 = true;
is_open = true;
} else {
if open_1 {
// this is the second open we've recieved
open_2 = true;
open_1 = false;
continue;
}
if close_1 {
// this is the second close we've received
output.push_str(&format!("<mark>{buffer}</mark>\n"));
buffer.clear();
open_1 = false;
open_2 = false;
close_1 = false;
is_open = false;
continue;
}
close_1 = true;
}
}
_ => {
if open_1 {
open_1 = false;
buffer.push('=');
}
if open_2 && is_open {
open_2 = false;
}
buffer.push(char);
}
}
}
}
fn parse_underline_line(output: &mut String, buffer: &mut String, line: &str) {
let mut open_1 = false;
let mut is_open = false;
let mut close_1 = false;
for char in line.chars() {
if open_1 && char != '~' {
is_open = false;
open_1 = false;
buffer.push('!');
}
if close_1 && char != '!' {
is_open = false;
close_1 = false;
buffer.push('~');
}
match char {
'~' => {
if open_1 {
open_1 = false;
is_open = true;
} else if is_open {
// open close
close_1 = true;
}
}
'!' => {
if close_1 {
// close
let mut s: Vec<&str> = buffer.split(";").collect();
let text = s.pop().unwrap_or(&"").trim();
let mut style = String::new();
for (i, mut x) in s.iter().enumerate() {
if i == 0 {
// color
if x == &"default" {
x = &"currentColor";
}
style.push_str(&format!("text-decoration-color: {x};"));
} else if i == 1 {
// style
if x == &"default" {
x = &"solid";
}
style.push_str(&format!("text-decoration-style: {x};"));
} else if i == 2 {
// line
if x == &"default" {
x = &"underline";
}
style.push_str(&format!("text-decoration-line: {x};"));
} else if i == 3 {
// thickness
if x == &"default" {
x = &"1px";
}
style.push_str(&format!("text-decoration-thickness: {x}px;"));
}
}
// defaults
if s.get(1).is_none() {
style.push_str(&format!("text-decoration-style: solid;"));
}
if s.get(2).is_none() {
style.push_str(&format!("text-decoration-line: underline;"));
}
if s.get(3).is_none() {
style.push_str(&format!("text-decoration-thickness: 1px;"));
}
// ...
output.push_str(&format!("<span style=\"{style}\">{text}</span>"));
buffer.clear();
open_1 = false;
is_open = false;
close_1 = false;
continue;
} else if is_open {
buffer.push(char);
continue;
}
// open
open_1 = true;
// flush buffer
output.push_str(&buffer);
buffer.clear();
}
_ => buffer.push(char),
}
}
}
fn parse_comment_line(output: &mut String, _: &mut String, line: &str) {
if line.contains("]:") && line.starts_with("[") {
return;
}
2025-07-24 01:06:03 -04:00
if line == "[..]" {
output.push_str(" ");
return;
}
output.push_str(line);
}
fn parse_image_size_line(output: &mut String, buffer: &mut String, line: &str) {
let mut image_possible = false;
let mut in_image = false;
let mut in_size = false;
let mut in_size_rhs = false;
let mut size_lhs = String::new();
let mut size_rhs = String::new();
if !line.contains("{") {
output.push_str(line);
return;
}
for char in line.chars() {
if image_possible && char != '[' {
image_possible = false;
output.push('!');
}
match char {
'[' => {
if image_possible {
in_image = true;
image_possible = false;
continue;
}
if in_image {
buffer.push(char);
} else {
output.push(char);
}
}
'{' => {
if in_image {
in_size = true;
continue;
}
if in_image {
buffer.push(char);
} else {
output.push(char);
}
}
':' => {
if in_size {
in_size_rhs = true;
continue;
}
if in_image {
buffer.push(char);
} else {
output.push(char);
}
}
'}' => {
if in_size && in_size_rhs {
// end
output.push_str(&format!(
2025-07-25 18:06:29 -04:00
"<span style=\"width: {}; height: {}; float: {}\" class=\"img_sizer\">![{buffer}</span>",
2025-07-24 15:10:59 -04:00
if is_numeric(&size_lhs) {
format!("{size_lhs}px")
} else {
size_lhs
},
if is_numeric(&size_rhs) {
format!("{size_rhs}px")
} else {
size_rhs
2025-07-25 18:06:29 -04:00
},
if buffer.ends_with("#left)") {
"left"
} else if buffer.ends_with("#right)") {
"right"
} else {
"unset"
2025-07-24 15:10:59 -04:00
}
));
size_lhs = String::new();
size_rhs = String::new();
in_image = false;
in_size = false;
in_size_rhs = false;
image_possible = false;
buffer.clear();
continue;
}
if in_image {
buffer.push(char);
} else {
output.push(char);
}
}
'!' => {
// flush buffer
output.push_str(&buffer);
buffer.clear();
// ...
image_possible = true
}
_ => {
if in_image {
if in_size {
if in_size_rhs {
size_rhs.push(char);
} else {
size_lhs.push(char);
}
} else {
buffer.push(char);
}
} else {
output.push(char)
}
}
}
}
}
2025-07-24 01:06:03 -04:00
fn parse_image_line(output: &mut String, buffer: &mut String, line: &str) {
let mut image_possible = false;
let mut in_image = false;
let mut in_alt = false;
let mut in_src = false;
let mut alt = String::new();
for char in line.chars() {
if image_possible && char != '[' {
image_possible = false;
output.push('!');
}
match char {
'[' => {
if image_possible {
in_image = true;
image_possible = false;
in_alt = true;
continue;
}
if in_image {
buffer.push(char);
} else {
output.push(char);
}
}
']' => {
if in_alt {
in_alt = false;
in_src = true;
continue;
}
output.push(char);
}
'(' => {
if in_src {
continue;
}
if in_image {
buffer.push(char);
} else {
output.push(char);
}
}
')' => {
if in_image {
// end
output.push_str(&format!(
2025-07-25 22:26:06 -04:00
"<img loading=\"lazy\" alt=\"{alt}\" src=\"{}\" style=\"float: {}\" />",
buffer.replace(" ", "$per20"),
2025-07-25 15:12:15 -04:00
if buffer.ends_with("#left") {
"left"
} else if buffer.ends_with("#right") {
"right"
} else {
"unset"
}
2025-07-24 01:06:03 -04:00
));
alt = String::new();
in_alt = false;
in_src = false;
in_image = false;
image_possible = false;
buffer.clear();
continue;
}
output.push(char);
}
'!' => {
// flush buffer
output.push_str(&buffer);
buffer.clear();
// ...
image_possible = true;
}
_ => {
if in_image {
if in_alt {
alt.push(char)
} else {
buffer.push(char);
}
} else {
output.push(char)
}
}
}
}
}
fn parse_link_line(output: &mut String, buffer: &mut String, line: &str) {
let mut in_link = false;
let mut in_text = false;
let mut in_src = false;
let mut text = String::new();
for (i, char) in line.chars().enumerate() {
match char {
'[' => {
// flush buffer
output.push_str(&buffer);
buffer.clear();
// scan for closing, otherwise quit
2025-07-24 15:10:59 -04:00
let haystack = slice(line, i..);
2025-07-24 01:06:03 -04:00
if !haystack.contains("]") {
output.push('[');
continue;
}
// ...
in_link = true;
in_text = true;
}
']' => {
if in_text {
in_text = false;
in_src = true;
continue;
}
output.push(char);
}
'(' => {
if in_src {
continue;
}
if in_link {
buffer.push(char);
} else {
output.push(char);
}
}
')' => {
if in_link {
// end
output.push_str(&format!(
"<a href=\"{buffer}\" rel=\"noopener noreferrer\">{text}</a>"
));
text = String::new();
in_text = false;
in_src = false;
in_link = false;
buffer.clear();
continue;
}
output.push(char);
}
_ => {
if in_link {
if in_text {
text.push(char)
} else {
buffer.push(char);
}
} else {
output.push(char)
}
}
}
}
}
/// Helper macro to quickly allow parsers to ignore fenced code blocks.
macro_rules! parser_ignores_pre {
($body:ident, $input:ident) => {{
let mut in_pre_block = false;
let mut output = String::new();
let mut buffer = String::new();
for line in $input.split("\n") {
2025-07-25 22:26:06 -04:00
if line.starts_with("```") | (line == "<style>") | (line == "</style>") {
in_pre_block = !in_pre_block;
output.push_str(&format!("{line}\n"));
continue;
}
if in_pre_block {
output.push_str(&format!("{line}\n"));
continue;
}
$body(&mut output, &mut buffer, line);
output.push_str(&format!("{buffer}\n"));
buffer.clear();
}
output
}};
2025-07-26 00:05:13 -04:00
($body:ident, $input:ident, $id:literal, ..) => {{
2025-07-26 00:05:13 -04:00
let mut in_pre_block = false;
let mut output = String::new();
let mut buffer = String::new();
let mut proc_str = String::new();
let mut pre_blocks = Vec::new();
let mut pre_idx = 0;
2025-07-26 00:05:13 -04:00
for line in $input.split("\n") {
2025-07-26 12:59:08 -04:00
if line.starts_with("```") {
2025-07-26 00:05:13 -04:00
in_pre_block = !in_pre_block;
pre_idx += 1;
pre_blocks.push(String::new());
pre_blocks[pre_idx - 1] += &(line.to_string() + "\n");
proc_str += &format!("$pre:{}.{pre_idx}\n", $id);
2025-07-26 00:05:13 -04:00
continue;
}
if in_pre_block {
pre_blocks[pre_idx - 1] += &(line.to_string() + "\n");
2025-07-26 00:05:13 -04:00
continue;
}
proc_str += &(line.to_string() + "\n");
}
$body(&mut output, &mut buffer, &proc_str);
output.push_str(&format!("{buffer}\n"));
buffer.clear();
for (mut i, block) in pre_blocks.iter().enumerate() {
i += 1;
if block == "```\n" {
output = output.replacen(&format!("$pre:{}.{i}", $id), "", 1);
continue;
}
output = output.replacen(&format!("$pre:{}.{i}", $id), &format!("{block}```\n"), 1);
}
2025-07-26 00:05:13 -04:00
output
}};
}
pub fn parse_text_color(input: &str) -> String {
parser_ignores_pre!(parse_text_color_line, input, 0, ..)
}
pub fn parse_highlight(input: &str) -> String {
parser_ignores_pre!(parse_highlight_line, input, 1, ..)
2025-07-20 18:29:43 -04:00
}
pub fn parse_underline(input: &str) -> String {
parser_ignores_pre!(parse_underline_line, input, 2, ..)
}
pub fn parse_comment(input: &str) -> String {
parser_ignores_pre!(parse_comment_line, input)
}
pub fn parse_image_size(input: &str) -> String {
parser_ignores_pre!(parse_image_size_line, input)
}
2025-07-24 01:06:03 -04:00
pub fn parse_image(input: &str) -> String {
parser_ignores_pre!(parse_image_line, input)
}
pub fn parse_link(input: &str) -> String {
parser_ignores_pre!(parse_link_line, input)
}
/// Match page definitions.
///
/// Each page is denoted with two at symbols, followed by the name of the page.
/// The page can also have an optional second argument (separated by a semicolon)
/// which accepts the "visible" value; marking the page as visible by default.
///
/// To close a page (after you're done with the page's content), just put two
/// at symbols with nothing else on the line.
///
/// You're able to put content AFTER the page closing line. This allows you to have
/// persistant content which is shared between every page. Only content within pages
/// is hidden when navigating to another page. This means everything in the entry
/// that isn't part of a page will remian throughout navigations.
///
/// # Example
/// ```md
/// @@ home; visible
/// this is the homepage which is shown by default!
/// @@
///
/// @@ about
/// this is the about page which is NOT shown by default! a link with an href of "#/about" will open this page
/// @@
/// ```
pub fn parse_page(input: &str) -> String {
let mut output = String::new();
let mut buffer = String::new();
let mut page_id = String::new();
let mut start_shown = false;
let mut in_page = false;
let mut in_pre = false;
for line in input.split("\n") {
if line.starts_with("```") {
in_pre = !in_pre;
if in_page {
buffer.push_str(&format!("{line}\n"));
} else {
output.push_str(&format!("{line}\n"));
}
continue;
}
if in_pre {
if in_page {
buffer.push_str(&format!("{line}\n"));
} else {
output.push_str(&format!("{line}\n"));
}
continue;
}
// not in pre
if line == "@@" {
// ending block
if in_page {
output.push_str(&format!(
"<div id=\"#/{page_id}\" class=\"{}subpage no_p_margin fadein\">\n{}\n</div>",
if !start_shown { "hidden " } else { "" },
render_markdown(&buffer) // recurse to render markdown since the renderer is ignoring the div content :/
));
start_shown = false;
in_page = false;
buffer.clear();
continue;
}
} else if line.starts_with("@@") {
if !in_page {
in_page = true;
let x = line.replace("@@", "").trim().to_string();
let id_parts: Vec<&str> = x.split(";").map(|x| x.trim()).collect();
page_id = id_parts[0].to_string();
if let Some(x) = id_parts.get(1) {
if *x == "visible" {
start_shown = true;
}
}
continue;
}
}
// otherwise
if in_page {
buffer.push_str(&format!("{line}\n"));
} else {
output.push_str(&format!("{line}\n"));
}
}
output
}
/// Parse the markdown syntax for the expandable `<details>` element.
///
/// Similar to the [`parse_page`] page definitions, details elements are denoted
/// with two ampersand symbols. The opening line should look like `&& [summary]; [open?]`.
///
/// The block is closed with a line of exactly two ampersand symbols.
///
/// # Example
/// ```md
/// && other summary
/// this element starts closed, but can be expanded
/// &&
/// ```
pub fn parse_details(input: &str) -> String {
let mut output = String::new();
let mut buffer = String::new();
let mut summary = String::new();
let mut in_details = false;
let mut in_pre = false;
for line in input.split("\n") {
if line.starts_with("```") {
in_pre = !in_pre;
if in_details {
buffer.push_str(&format!("{line}\n"));
} else {
output.push_str(&format!("{line}\n"));
}
continue;
}
if in_pre {
if in_details {
buffer.push_str(&format!("{line}\n"));
} else {
output.push_str(&format!("{line}\n"));
}
continue;
}
// not in pre
if line == "&&" {
// ending block
if in_details {
output.push_str(&format!(
"<details><summary>{summary}</summary><div class=\"content\">{}</div></details>",
render_markdown(&buffer),
));
in_details = false;
buffer.clear();
continue;
}
} else if line.starts_with("&&") {
if !in_details {
in_details = true;
summary = line.replace("&&", "").trim().to_string();
continue;
}
}
// otherwise
if in_details {
buffer.push_str(&format!("{line}\n"));
} else {
output.push_str(&format!("{line}\n"));
}
}
output
}