From f8dac8f4917ea1bc5367b576563d3eafcd07ec47 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 21 Jul 2025 22:28:43 -0400 Subject: [PATCH] add: near perfect metadata compatibility --- Cargo.lock | 6 +- Cargo.toml | 4 +- app/public/app.js | 4 +- app/public/style.css | 9 +- app/templates_src/view.lisp | 4 + src/markdown.rs | 187 +++++++++++++++++++++++++++- src/model.rs | 235 ++++++++++++++++++++++++++++++++++-- src/routes.rs | 7 +- 8 files changed, 434 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9bde7fd..b4f4e6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,7 +84,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "attobin" -version = "0.2.0" +version = "0.2.1" dependencies = [ "axum", "axum-extra", @@ -2586,9 +2586,9 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "12.0.4" +version = "12.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a7a1ea669f94f12da3c007012c6fbac263cfc74c964c460e19da0d9c7f6abf1" +checksum = "11c2ba2be9c92a4ac566b9c3b615d7311b3d0e98b175ad84e81b44644b34dd8f" dependencies = [ "ammonia", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 2b70eb9..1e0be95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "attobin" -version = "0.2.0" +version = "0.2.1" edition = "2024" authors = ["trisuaso"] repository = "https://trisua.com/t/tetratto" @@ -9,7 +9,7 @@ homepage = "https://tetratto.com" [dependencies] tetratto-core = "12.0.0" -tetratto-shared = "12.0.4" +tetratto-shared = "12.0.5" tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] } pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } diff --git a/app/public/app.js b/app/public/app.js index ba63155..987273b 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -69,7 +69,7 @@ function check_message() { if (message) { element.style.marginBottom = "1rem"; element.style.paddingLeft = "1rem"; - element.innerHTML += `
  • ${message.replaceAll('"', "")}
  • `; + element.innerHTML = `
  • ${message.replaceAll('"', "")}
  • `; } // clear cookies @@ -83,7 +83,7 @@ globalThis.show_message = (message, message_good = true) => { const element = document.getElementById("messages"); element.style.marginBottom = "1rem"; element.style.paddingLeft = "1rem"; - element.innerHTML += `
  • ${message.replaceAll('"', "")}
  • `; + element.innerHTML = `
  • ${message.replaceAll('"', "")}
  • `; }; check_message(); diff --git a/app/public/style.css b/app/public/style.css index d8b6360..486357b 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -111,6 +111,11 @@ article { } } +.container { + margin: 10px auto 0; + width: 100%; +} + .content_container { margin: var(--pad-2) auto; width: 100%; @@ -163,7 +168,7 @@ video { justify-content: center; align-items: center; gap: var(--gap-2); - padding: var(--pad-2) var(--pad-4); + padding: var(--pad-2) calc(var(--pad-3) * 1.5); cursor: pointer; background: var(--color-raised); color: var(--color-text); @@ -198,7 +203,7 @@ video { /* input */ input { --h: 36px; - padding: var(--pad-2) var(--pad-4); + padding: var(--pad-2) calc(var(--pad-3) * 1.5); background: var(--color-raised); color: var(--color-text); outline: none; diff --git a/app/templates_src/view.lisp b/app/templates_src/view.lisp index b6cb949..e3c252e 100644 --- a/app/templates_src/view.lisp +++ b/app/templates_src/view.lisp @@ -3,7 +3,11 @@ (title (text "{{ entry.slug }}")) (text "{%- endif %} {{ metadata_head|safe }}") + +(text "{% if metadata.page_icon|length == 0 -%}") (link ("rel" "icon") ("href" "/public/favicon.svg")) +(text "{%- endif %}") + (text "{% endblock %} {% block body %}") (div ("class" "flex flex-col gap-2") diff --git a/src/markdown.rs b/src/markdown.rs index 6f0cfc1..24b1bd5 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -1,3 +1,188 @@ +use std::collections::HashSet; + pub fn render_markdown(input: &str) -> String { - tetratto_shared::markdown::render_markdown(input, false) + let html = tetratto_shared::markdown::render_markdown_dirty(&parse_text_color( + &parse_highlight(input), + )); + + 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"); + + tetratto_shared::markdown::clean_html(html, allowed_attributes) +} + +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; + + for char in line.chars() { + 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!( + "{buffer}\n" + )); + + color_buffer.clear(); + buffer.clear(); + + // ... + in_main_buffer = false; + close_1 = false; + continue; + } + + // start + // 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!("{buffer}\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); + } + } + } +} + +/// 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") { + if line.starts_with("```") { + 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 + }}; +} + +pub fn parse_text_color(input: &str) -> String { + parser_ignores_pre!(parse_text_color_line, input) +} + +pub fn parse_highlight(input: &str) -> String { + parser_ignores_pre!(parse_highlight_line, input) } diff --git a/src/model.rs b/src/model.rs index 2318e6d..5deb76c 100644 --- a/src/model.rs +++ b/src/model.rs @@ -10,6 +10,7 @@ pub struct Entry { pub created: usize, pub edited: usize, pub content: String, + #[serde(default)] pub metadata: String, } @@ -69,7 +70,7 @@ pub struct EntryMetadata { #[validate(max_length = 256)] pub page_description: String, /// The favicon of the page. - #[serde(default, alias = "PAGE_icon")] + #[serde(default, alias = "PAGE_ICON")] #[validate(max_length = 128)] pub page_icon: String, /// The title of the page shown in external embeds. @@ -99,38 +100,171 @@ pub struct EntryMetadata { #[serde(default, alias = "ACCESS_EASY_READ")] #[validate(max_length = 32)] pub access_easy_read: String, - /// The color of the text in the inner container. - #[serde(default, alias = "CONTAINER_INNER_FOREGROUND")] + /// The padding of the container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_PADDING")] #[validate(max_length = 32)] - pub container_inner_foreground: String, + pub container_padding: String, + /// The maximum width of the container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_MAX_WIDTH")] + #[validate(max_length = 16)] + pub container_max_width: String, + /// The padding of the container. + /// The color of the text in the inner container. + #[serde(default, alias = "CONTAINER_INNER_FOREGROUND_COLOR")] + #[validate(max_length = 32)] + pub container_inner_foreground_color: String, /// The background of the inner container. /// /// Syntax: #[serde(default, alias = "CONTAINER_INNER_BACKGROUND")] #[validate(max_length = 256)] pub container_inner_background: String, - /// The color of the text in the outer container. - #[serde(default, alias = "CONTAINER_OUTER_FOREGROUND")] + /// The background of the inner container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_INNER_BACKGROUND_COLOR")] #[validate(max_length = 32)] - pub container_outer_foreground: String, + pub container_inner_background_color: String, + /// The background of the inner container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_INNER_BACKGROUND_IMAGE")] + #[validate(max_length = 128)] + pub container_inner_background_image: String, + /// The background of the inner container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_INNER_BACKGROUND_IMAGE_REPEAT")] + #[validate(max_length = 16)] + pub container_inner_background_image_repeat: String, + /// The background of the inner container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_INNER_BACKGROUND_IMAGE_POSITION")] + #[validate(max_length = 16)] + pub container_inner_background_image_position: String, + /// The background of the inner container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_INNER_BACKGROUND_IMAGE_SIZE")] + #[validate(max_length = 16)] + pub container_inner_background_image_size: String, + /// The color of the text in the outer container. + #[serde(default, alias = "CONTAINER_OUTER_FOREGROUND_COLOR")] + #[validate(max_length = 32)] + pub container_outer_foreground_color: String, /// The background of the outer container. /// /// Syntax: #[serde(default, alias = "CONTAINER_OUTER_BACKGROUND")] #[validate(max_length = 256)] pub container_outer_background: String, + /// The background of the outer container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_OUTER_BACKGROUND_COLOR")] + #[validate(max_length = 32)] + pub container_outer_background_color: String, + /// The background of the outer container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_OUTER_BACKGROUND_IMAGE")] + #[validate(max_length = 128)] + pub container_outer_background_image: String, + /// The background of the outer container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_OUTER_BACKGROUND_IMAGE_REPEAT")] + #[validate(max_length = 16)] + pub container_outer_background_image_repeat: String, + /// The background of the outer container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_OUTER_BACKGROUND_IMAGE_POSITION")] + #[validate(max_length = 16)] + pub container_outer_background_image_position: String, + /// The background of the outer container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_OUTER_BACKGROUND_IMAGE_SIZE")] + #[validate(max_length = 16)] + pub container_outer_background_image_size: String, /// The border around the container. /// /// Syntax: #[serde(default, alias = "CONTAINER_BORDER")] #[validate(max_length = 256)] pub container_border: String, + /// The border around the container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_BORDER_IMAGE")] + #[validate(max_length = 128)] + pub container_border_image: String, + /// The border around the container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_BORDER_IMAGE_SLICE")] + #[validate(max_length = 16)] + pub container_border_image_slice: String, + /// The border around the container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_BORDER_IMAGE_WIDTH")] + #[validate(max_length = 16)] + pub container_border_image_width: String, + /// The border around the container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_BORDER_IMAGE_OUTSET")] + #[validate(max_length = 32)] + pub container_border_image_outset: String, + /// The border around the container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_BORDER_IMAGE_REPEAT")] + #[validate(max_length = 16)] + pub container_border_image_repeat: String, + /// The border around the container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_BORDER_COLOR")] + #[validate(max_length = 32)] + pub container_border_color: String, + /// The border around the container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_BORDER_WIDTH")] + #[validate(max_length = 16)] + pub container_border_width: String, + /// The border around the container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_BORDER_STYLE")] + #[validate(max_length = 16)] + pub container_border_style: String, + /// The border around the container. + /// + /// Syntax: + #[serde(default, alias = "CONTAINER_BORDER_RADIUS")] + #[validate(max_length = 16)] + pub container_border_radius: String, /// The shadow around the container. /// /// Syntax: #[serde(default, alias = "CONTAINER_SHADOW")] #[validate(max_length = 32)] pub container_shadow: String, + /// The shadow under text. + /// + /// Syntax: + #[serde(default, alias = "CONTENT_TEXT_SHADOW")] + #[validate(max_length = 32)] + pub content_text_shadow: String, /// The name of a font from Google Fonts to use. #[serde(default, alias = "CONTENT_FONT")] #[validate(max_length = 32)] @@ -166,6 +300,17 @@ macro_rules! metadata_css { )); } }; + + ($selector:literal, $property:literal, $format:literal, $self:ident.$field:ident->$output:ident) => { + if !$self.$field.is_empty() { + $output.push_str(&format!( + "{} {{ {}: {}; }}\n", + $selector, + $property, + format!($format, EntryMetadata::css_escape(&$self.$field)) + )); + } + }; } impl EntryMetadata { @@ -233,12 +378,34 @@ impl EntryMetadata { pub fn css(&self) -> String { let mut output = "" } + + pub fn ini_to_toml(input: &str) -> String { + let mut output = String::new(); + + for line in input.split("\n") { + if !line.contains("=") { + // no equal sign, skip line (some other toml function) + continue; + } + + let mut key = String::new(); + let mut value = String::new(); + + let mut chars = line.chars(); + let mut in_value = false; + + while let Some(char) = chars.next() { + if !in_value { + key.push(char); + + if char == '=' { + in_value = true; + } + + continue; + } else { + value.push(char); + continue; + } + } + + value = value.trim().to_string(); + + // determine if we need to stringify + let mut is_numeric = false; + + for char in value.chars() { + is_numeric = char.is_numeric(); + } + + if !is_numeric && !value.starts_with("[") && !value.starts_with("\"") { + value = format!("\"{value}\""); + } + + // push line + output.push_str(&format!("{key} {value}\n")); + } + + output + } } diff --git a/src/routes.rs b/src/routes.rs index 18f674f..135a23b 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -104,7 +104,8 @@ async fn view_request( }; // check metadata - let metadata: EntryMetadata = match toml::from_str(&entry.metadata) { + let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&entry.metadata)) + { Ok(x) => x, Err(e) => { let mut ctx = default_context(&data, &build_code); @@ -269,7 +270,7 @@ async fn create_request( } // check metadata - let metadata: EntryMetadata = match toml::from_str(&req.metadata) { + let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) { Ok(x) => x, Err(e) => return Json(Error::MiscError(e.to_string()).into()), }; @@ -359,7 +360,7 @@ async fn edit_request( } // check metadata - let metadata: EntryMetadata = match toml::from_str(&req.metadata) { + let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) { Ok(x) => x, Err(e) => return Json(Error::MiscError(e.to_string()).into()), };