fluffle/src/model.rs
2025-07-25 18:33:16 -04:00

597 lines
24 KiB
Rust

use crate::markdown::is_numeric;
use serde::{Deserialize, Serialize};
use serde_valid::Validate;
use std::fmt::Display;
#[derive(Serialize, Deserialize)]
pub struct Entry {
pub slug: String,
pub edit_code: String,
pub salt: String,
pub created: usize,
pub edited: usize,
pub content: String,
#[serde(default)]
pub metadata: String,
/// The IP address of the last editor of the entry.
#[serde(default)]
pub last_edit_from: String,
}
#[derive(Serialize, Deserialize, PartialEq, Eq)]
pub enum RecommendedTheme {
#[serde(alias = "none")]
None,
#[serde(alias = "light")]
Light,
#[serde(alias = "dark")]
Dark,
}
impl Default for RecommendedTheme {
fn default() -> Self {
Self::None
}
}
#[derive(Serialize, Deserialize, PartialEq, Eq)]
pub enum TextAlignment {
#[serde(alias = "left")]
Left,
#[serde(alias = "center")]
Center,
#[serde(alias = "right")]
Right,
#[serde(alias = "justify")]
Justify,
}
impl Default for TextAlignment {
fn default() -> Self {
Self::Left
}
}
impl Display for TextAlignment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Left => "left",
Self::Center => "center",
Self::Right => "right",
Self::Justify => "justify",
})
}
}
#[derive(Serialize, Deserialize, Validate, Default)]
pub struct EntryMetadata {
/// The title of the page.
#[serde(default, alias = "PAGE_TITLE")]
#[validate(max_length = 128)]
pub page_title: String,
/// The description of the page.
#[serde(default, alias = "PAGE_DESCRIPTION")]
#[validate(max_length = 256)]
pub page_description: String,
/// The favicon of the page.
#[serde(default, alias = "PAGE_ICON")]
#[validate(max_length = 128)]
pub page_icon: String,
/// The title of the page shown in external embeds.
#[serde(default, alias = "SHARE_TITLE")]
#[validate(max_length = 128)]
pub share_title: String,
/// The description of the page shown in external embeds.
#[serde(default, alias = "SHARE_DESCRIPTION")]
#[validate(max_length = 256)]
pub share_description: String,
/// The image shown in external embeds.
#[serde(default, alias = "SHARE_IMAGE")]
#[validate(max_length = 128)]
pub share_image: String,
/// If views are counted and shown for this entry.
#[serde(default, alias = "OPTION_DISABLE_VIEWS")]
pub option_disable_views: bool,
/// If this entry shows up in search engines.
#[serde(default, alias = "OPTION_DISABLE_SEARCH_ENGINE")]
pub option_disable_search_engine: bool,
/// The theme that is automatically used when this entry is viewed.
#[serde(default, alias = "ACCESS_RECOMMENDED_THEME")]
pub access_recommended_theme: RecommendedTheme,
/// The slug of the easy-to-read (no metadata) version of this entry.
///
/// Should not begin with "/".
#[serde(default, alias = "ACCESS_EASY_READ")]
#[validate(max_length = 32)]
pub access_easy_read: String,
/// The padding of the container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/padding>
#[serde(default, alias = "CONTAINER_PADDING")]
#[validate(max_length = 32)]
pub container_padding: String,
/// The maximum width of the container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/max-width>
#[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: <https://developer.mozilla.org/en-US/docs/Web/CSS/background>
#[serde(default, alias = "CONTAINER_INNER_BACKGROUND")]
#[validate(max_length = 256)]
pub container_inner_background: String,
/// The background of the inner container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/background-color>
#[serde(default, alias = "CONTAINER_INNER_BACKGROUND_COLOR")]
#[validate(max_length = 32)]
pub container_inner_background_color: String,
/// The background of the inner container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/background-image>
#[serde(default, alias = "CONTAINER_INNER_BACKGROUND_IMAGE")]
#[validate(max_length = 128)]
pub container_inner_background_image: String,
/// The background of the inner container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/background-repeat>
#[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: <https://developer.mozilla.org/en-US/docs/Web/CSS/background-position>
#[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: <https://developer.mozilla.org/en-US/docs/Web/CSS/background-size>
#[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: <https://developer.mozilla.org/en-US/docs/Web/CSS/background>
#[serde(default, alias = "CONTAINER_OUTER_BACKGROUND")]
#[validate(max_length = 256)]
pub container_outer_background: String,
/// The background of the outer container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/background-color>
#[serde(default, alias = "CONTAINER_OUTER_BACKGROUND_COLOR")]
#[validate(max_length = 32)]
pub container_outer_background_color: String,
/// The background of the outer container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/background-image>
#[serde(default, alias = "CONTAINER_OUTER_BACKGROUND_IMAGE")]
#[validate(max_length = 128)]
pub container_outer_background_image: String,
/// The background of the outer container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/background-repeat>
#[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: <https://developer.mozilla.org/en-US/docs/Web/CSS/background-position>
#[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: <https://developer.mozilla.org/en-US/docs/Web/CSS/background-size>
#[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: <https://developer.mozilla.org/en-US/docs/Web/CSS/border>
#[serde(default, alias = "CONTAINER_BORDER")]
#[validate(max_length = 256)]
pub container_border: String,
/// The border around the container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/border-image>
#[serde(default, alias = "CONTAINER_BORDER_IMAGE")]
#[validate(max_length = 128)]
pub container_border_image: String,
/// The border around the container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/border-image-slice>
#[serde(default, alias = "CONTAINER_BORDER_IMAGE_SLICE")]
#[validate(max_length = 16)]
pub container_border_image_slice: String,
/// The border around the container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/border-image-width>
#[serde(default, alias = "CONTAINER_BORDER_IMAGE_WIDTH")]
#[validate(max_length = 16)]
pub container_border_image_width: String,
/// The border around the container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/border-image-outset>
#[serde(default, alias = "CONTAINER_BORDER_IMAGE_OUTSET")]
#[validate(max_length = 32)]
pub container_border_image_outset: String,
/// The border around the container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/border-image-repeat>
#[serde(default, alias = "CONTAINER_BORDER_IMAGE_REPEAT")]
#[validate(max_length = 16)]
pub container_border_image_repeat: String,
/// The border around the container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/border-color>
#[serde(default, alias = "CONTAINER_BORDER_COLOR")]
#[validate(max_length = 32)]
pub container_border_color: String,
/// The border around the container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/border-width>
#[serde(default, alias = "CONTAINER_BORDER_WIDTH")]
#[validate(max_length = 16)]
pub container_border_width: String,
/// The border around the container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/border-style>
#[serde(default, alias = "CONTAINER_BORDER_STYLE")]
#[validate(max_length = 16)]
pub container_border_style: String,
/// The border around the container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/border-radius>
#[serde(default, alias = "CONTAINER_BORDER_RADIUS")]
#[validate(max_length = 16)]
pub container_border_radius: String,
/// The shadow around the container.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow>
#[serde(default, alias = "CONTAINER_SHADOW")]
#[validate(max_length = 32)]
pub container_shadow: String,
/// If the container is a flexbox.
#[serde(default, alias = "CONTAINER_FLEX")]
pub container_flex: bool,
/// The direction of the container's content (if container has flex enabled).
#[serde(default, alias = "CONTAINER_FLEX_DIRECTION")]
#[validate(max_length = 16)]
pub container_flex_direction: String,
/// The gap of the container's content (if container has flex enabled).
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/gap>
#[serde(default, alias = "CONTAINER_FLEX_GAP")]
#[validate(max_length = 16)]
pub container_flex_gap: String,
/// If the container's content wraps (if container has flex enabled).
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/flex-wrap>
#[serde(default, alias = "CONTAINER_FLEX_WRAP")]
pub container_flex_wrap: bool,
/// The alignment of the container's content horizontally (if container has flex enabled).
///
/// Swapped to vertical when direction is `column*`.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/justify-content>
#[serde(default, alias = "CONTAINER_FLEX_ALIGN_HORIZONTAL")]
#[validate(max_length = 16)]
pub container_flex_align_horizontal: String,
/// The alignment of the container's content vertically (if container has flex enabled).
///
/// Swapped to horizontal when direction is `column*`.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/align-items>
#[serde(default, alias = "CONTAINER_FLEX_ALIGN_VERTICAL")]
#[validate(max_length = 16)]
pub container_flex_align_vertical: String,
/// The shadow under text.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/text-shadow>
#[serde(default, alias = "CONTENT_TEXT_SHADOW")]
#[validate(max_length = 32)]
pub content_text_shadow: String,
/// The shadow under text.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/text-shadow>
#[serde(default, alias = "CONTENT_TEXT_SHADOW_COLOR")]
#[validate(max_length = 32)]
pub content_text_shadow_color: String,
/// The shadow under text.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/text-shadow>
#[serde(default, alias = "CONTENT_TEXT_SHADOW_OFFSET")]
#[validate(max_length = 32)]
pub content_text_shadow_offset: String,
/// The shadow under text.
///
/// Syntax: <https://developer.mozilla.org/en-US/docs/Web/CSS/text-shadow>
#[serde(default, alias = "CONTENT_TEXT_SHADOW_BLUR")]
#[validate(max_length = 32)]
pub content_text_shadow_blur: String,
/// The name of a font from Google Fonts to use.
#[serde(default, alias = "CONTENT_FONT")]
#[validate(max_length = 32)]
pub content_font: String,
/// The weight to use for the body text.
#[serde(default, alias = "CONTENT_FONT_WEIGHT")]
pub content_font_weight: u32,
/// The text size of elements by element tag.
///
/// # Example
/// ```toml
/// # ...
/// content_text_size = [["h1", "16px"]]
/// ```
#[serde(default, alias = "CONTENT_TEXT_SIZE")]
pub content_text_size: Vec<(String, String)>,
/// The default text alignment.
#[serde(default, alias = "CONTENT_TEXT_ALIGN")]
pub content_text_align: TextAlignment,
/// The color of links.
#[serde(default, alias = "CONTENT_LINK_COLOR")]
pub content_link_color: String,
/// If paragraph elements have a margin below them.
#[serde(default, alias = "CONTENT_DISABLE_PARAGRAPH_MARGIN")]
pub content_disable_paragraph_margin: bool,
}
macro_rules! metadata_css {
($selector:literal, $property:literal, $self:ident.$field:ident->$output:ident) => {
if !$self.$field.is_empty() {
$output.push_str(&format!(
"{} {{ {}: {}; }}\n",
$selector,
$property,
EntryMetadata::css_escape(&$self.$field)
));
}
};
($selector:literal, $property:literal !important, $self:ident.$field:ident->$output:ident) => {
if !$self.$field.is_empty() {
$output.push_str(&format!(
"{} {{ {}: {} !important; }}\n",
$selector,
$property,
EntryMetadata::css_escape(&$self.$field)
));
}
};
($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 {
pub fn head_tags(&self) -> String {
let mut output = String::new();
if !self.page_title.is_empty() {
output.push_str(&format!("<title>{}</title>", self.page_title));
}
if !self.page_description.is_empty() {
output.push_str(&format!(
"<meta name=\"description\" content=\"{}\" />",
self.page_description.replace("\"", "\\\"")
));
}
if !self.page_icon.is_empty() {
output.push_str(&format!(
"<link rel=\"icon\" href=\"{}\" />",
self.page_icon.replace("\"", "\\\"")
));
}
if !self.share_title.is_empty() {
output.push_str(&format!(
"<meta property=\"og:title\" content=\"{}\" /><meta property=\"twitter:title\" content=\"{}\" />",
self.share_title.replace("\"", "\\\""),
self.share_title.replace("\"", "\\\"")
));
}
if !self.share_description.is_empty() {
output.push_str(&format!(
"<meta property=\"og:description\" content=\"{}\" /><meta property=\"twitter:description\" content=\"{}\" />",
self.share_description.replace("\"", "\\\""),
self.share_description.replace("\"", "\\\"")
));
}
if !self.share_image.is_empty() {
output.push_str(&format!(
"<meta property=\"og:image\" content=\"{}\" /><meta property=\"twitter:image\" content=\"{}\" />",
self.share_image.replace("\"", "\\\""),
self.share_image.replace("\"", "\\\"")
));
}
if !self.content_font.is_empty() {
output.push_str(&format!(
"<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">
<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>
<link href=\"https://fonts.googleapis.com/css2?family={}&display=swap\" rel=\"stylesheet\">",
self.content_font.replace(" ", "+").replace("_", "+"),
));
}
output
}
pub fn css_escape(input: &str) -> String {
input.replace("}", "").replace(";", "").replace("/*", "")
}
pub fn css(&self) -> String {
let mut output = "<style>".to_string();
metadata_css!(".container", "padding", self.container_padding->output);
metadata_css!(".container", "max-width", self.container_max_width->output);
metadata_css!(".container", "color", self.container_inner_foreground_color->output);
metadata_css!(".container", "background", self.container_inner_background->output);
metadata_css!(".container", "background-color", self.container_inner_background_color->output);
metadata_css!(".container", "background-image", "url('{}')", self.container_inner_background_image->output);
metadata_css!(".container", "background-repeat", self.container_inner_background_image_repeat->output);
metadata_css!(".container", "background-position", self.container_inner_background_image_position->output);
metadata_css!(".container", "background-size", self.container_inner_background_image_size->output);
metadata_css!("body", "color", self.container_outer_foreground_color->output);
metadata_css!("body", "background", self.container_outer_background->output);
metadata_css!("body", "background-color", self.container_outer_background_color->output);
metadata_css!("body", "background-image", "url('{}')", self.container_outer_background_image->output);
metadata_css!("body", "background-repeat", self.container_outer_background_image_repeat->output);
metadata_css!("body", "background-position", self.container_outer_background_image_position->output);
metadata_css!("body", "background-size", self.container_outer_background_image_size->output);
metadata_css!(".container", "border", self.container_border->output);
metadata_css!(".container", "border-image", "url('{}')", self.container_border_image->output);
metadata_css!(".container", "border-image-slice", self.container_border_image_slice->output);
metadata_css!(".container", "border-image-repeat", self.container_border_image_repeat->output);
metadata_css!(".container", "border-image-outset", self.container_border_image_outset->output);
metadata_css!(".container", "border-image-width", self.container_border_image_width->output);
metadata_css!(".container", "border-color", self.container_border_color->output);
metadata_css!(".container", "border-style", self.container_border_style->output);
metadata_css!(".container", "border-width", self.container_border_width->output);
metadata_css!(".container", "border-radius", self.container_border_radius->output);
metadata_css!(".container", "box-shadow", self.container_shadow->output);
metadata_css!(".container", "text-shadow", self.content_text_shadow->output);
metadata_css!("*, html *", "--color-link" !important, self.content_link_color->output);
if self.content_text_align != TextAlignment::Left {
output.push_str(&format!(
".container {{ text-align: {}; }}\n",
EntryMetadata::css_escape(&self.content_text_align.to_string())
));
}
for (element, size) in &self.content_text_size {
if element == "*" {
output.push_str(&format!(
".container, .container * {{ font-size: {}; }}\n",
EntryMetadata::css_escape(&size)
));
continue;
}
output.push_str(&format!(
".container {} {{ font-size: {}; }}\n",
element,
EntryMetadata::css_escape(&size)
));
}
if !self.content_font.is_empty() {
output.push_str(&format!(
".container {{ font-family: \"{}\", system-ui; }}",
self.content_font.replace("_", " ")
));
}
if self.content_font_weight != 0 {
output.push_str(&format!(
".container {{ font-weight: {}; }}",
self.content_font_weight
));
}
if !self.content_text_shadow_color.is_empty() {
output.push_str(&format!(
".container {{ text-shadow: {} {} {}; }}",
self.content_text_shadow_offset,
self.content_text_shadow_blur,
self.content_text_shadow_color
));
}
if self.container_flex {
output.push_str(".container { display: flex; }");
metadata_css!(".container", "gap", self.container_flex_gap->output);
metadata_css!(".container", "flex-direction", self.container_flex_direction->output);
metadata_css!(".container", "align-items", self.container_flex_align_vertical->output);
metadata_css!(".container", "justify-content", self.container_flex_align_horizontal->output);
if self.container_flex_wrap {
output.push_str(".container { flex-wrap: wrap; }");
}
}
if self.content_disable_paragraph_margin {
output.push_str(".container p { margin: 0; }");
}
output + "</style>"
}
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 is_numeric = is_numeric(&value);
if !is_numeric
&& !value.starts_with("[")
&& !value.starts_with("\"")
&& value != "true"
&& value != "false"
|| value.starts_with("#")
{
value = format!("\"{value}\"");
}
// push line
output.push_str(&format!("{key} {value}\n"));
}
output
}
}