add: entry metadata
This commit is contained in:
parent
d80368e6c2
commit
b505199492
11 changed files with 631 additions and 45 deletions
264
src/model.rs
264
src/model.rs
|
@ -1,4 +1,6 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use serde_valid::Validate;
|
||||
use std::fmt::Display;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Entry {
|
||||
|
@ -8,4 +10,266 @@ pub struct Entry {
|
|||
pub created: usize,
|
||||
pub edited: usize,
|
||||
pub content: String,
|
||||
pub metadata: 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 color of the text in the inner container.
|
||||
#[serde(default, alias = "CONTAINER_INNER_FOREGROUND")]
|
||||
#[validate(max_length = 32)]
|
||||
pub container_inner_foreground: 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 color of the text in the outer container.
|
||||
#[serde(default, alias = "CONTAINER_OUTER_FOREGROUND")]
|
||||
#[validate(max_length = 32)]
|
||||
pub container_outer_foreground: 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 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 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,
|
||||
/// 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_TEXT_LINK_COLOR")]
|
||||
pub content_text_link_color: String,
|
||||
}
|
||||
|
||||
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)
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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={}:wght@100..900&display=swap\" rel=\"stylesheet\">",
|
||||
self.content_font.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", "color", self.container_inner_foreground->output);
|
||||
metadata_css!(".container", "background", self.container_inner_background->output);
|
||||
metadata_css!("body", "color", self.container_outer_foreground->output);
|
||||
metadata_css!("body", "background", self.container_outer_background->output);
|
||||
metadata_css!(".container", "border", self.container_border->output);
|
||||
metadata_css!(".container", "box-shadow", self.container_shadow->output);
|
||||
metadata_css!(".container a", "color", self.content_text_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 {
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
if self.content_font_weight != 0 {
|
||||
output.push_str(&format!(
|
||||
".container {{ font-weight: {}; }}",
|
||||
self.content_font_weight
|
||||
));
|
||||
}
|
||||
|
||||
output + "</style>"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
use crate::{State, model::Entry};
|
||||
use crate::{
|
||||
State,
|
||||
model::{Entry, EntryMetadata},
|
||||
};
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
extract::Path,
|
||||
|
@ -6,6 +9,7 @@ use axum::{
|
|||
routing::{get, get_service, post},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_valid::Validate;
|
||||
use tera::Context;
|
||||
use tetratto_core::{
|
||||
model::{
|
||||
|
@ -99,38 +103,66 @@ async fn view_request(
|
|||
}
|
||||
};
|
||||
|
||||
let (views_id, views) = match data
|
||||
.query(&SimplifiedQuery {
|
||||
query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)),
|
||||
mode: AppDataSelectMode::One(0),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(r) => match r {
|
||||
AppDataQueryResult::One(r) => (r.id, r.value.parse::<usize>().unwrap()),
|
||||
AppDataQueryResult::Many(_) => unreachable!(),
|
||||
},
|
||||
// check metadata
|
||||
let metadata: EntryMetadata = match toml::from_str(&entry.metadata) {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
let mut ctx = default_context(&data, &build_code);
|
||||
ctx.insert("error", &e.to_string());
|
||||
|
||||
return Html(tera.render("error.lisp", &ctx).unwrap());
|
||||
}
|
||||
};
|
||||
|
||||
// count view
|
||||
if let Err(e) = data.update(views_id, (views + 1).to_string()).await {
|
||||
if let Err(e) = metadata.validate() {
|
||||
let mut ctx = default_context(&data, &build_code);
|
||||
ctx.insert("error", &e.to_string());
|
||||
|
||||
return Html(tera.render("error.lisp", &ctx).unwrap());
|
||||
}
|
||||
|
||||
// pull views
|
||||
let views = if !metadata.option_disable_views {
|
||||
match data
|
||||
.query(&SimplifiedQuery {
|
||||
query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)),
|
||||
mode: AppDataSelectMode::One(0),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(r) => match r {
|
||||
AppDataQueryResult::One(r) => {
|
||||
// count view
|
||||
let views = r.value.parse::<usize>().unwrap();
|
||||
|
||||
if let Err(e) = data.update(r.id, (views + 1).to_string()).await {
|
||||
let mut ctx = default_context(&data, &build_code);
|
||||
ctx.insert("error", &e.to_string());
|
||||
|
||||
return Html(tera.render("error.lisp", &ctx).unwrap());
|
||||
}
|
||||
|
||||
views
|
||||
}
|
||||
AppDataQueryResult::Many(_) => unreachable!(),
|
||||
},
|
||||
Err(e) => {
|
||||
let mut ctx = default_context(&data, &build_code);
|
||||
ctx.insert("error", &e.to_string());
|
||||
|
||||
return Html(tera.render("error.lisp", &ctx).unwrap());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// ...
|
||||
let mut ctx = default_context(&data, &build_code);
|
||||
|
||||
ctx.insert("entry", &entry);
|
||||
ctx.insert("views", &views);
|
||||
ctx.insert("metadata", &metadata);
|
||||
ctx.insert("metadata_head", &metadata.head_tags());
|
||||
ctx.insert("metadata_css", &metadata.css());
|
||||
|
||||
Html(tera.render("view.lisp", &ctx).unwrap())
|
||||
}
|
||||
|
@ -201,6 +233,8 @@ async fn exists_request(
|
|||
#[derive(Deserialize)]
|
||||
struct CreateEntry {
|
||||
content: String,
|
||||
#[serde(default)]
|
||||
metadata: String,
|
||||
#[serde(default = "default_random")]
|
||||
slug: String,
|
||||
#[serde(default = "default_random")]
|
||||
|
@ -234,6 +268,16 @@ async fn create_request(
|
|||
return Json(Error::DataTooLong("content".to_string()).into());
|
||||
}
|
||||
|
||||
// check metadata
|
||||
let metadata: EntryMetadata = match toml::from_str(&req.metadata) {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
|
||||
};
|
||||
|
||||
if let Err(e) = metadata.validate() {
|
||||
return Json(Error::MiscError(e.to_string()).into());
|
||||
}
|
||||
|
||||
// check for existing
|
||||
if data
|
||||
.query(&SimplifiedQuery {
|
||||
|
@ -260,6 +304,7 @@ async fn create_request(
|
|||
created,
|
||||
edited: created,
|
||||
content: req.content,
|
||||
metadata: req.metadata,
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
|
@ -292,6 +337,8 @@ struct EditEntry {
|
|||
#[serde(default)]
|
||||
new_edit_code: Option<String>,
|
||||
#[serde(default)]
|
||||
metadata: String,
|
||||
#[serde(default)]
|
||||
delete: bool,
|
||||
}
|
||||
|
||||
|
@ -311,6 +358,16 @@ async fn edit_request(
|
|||
return Json(Error::DataTooLong("content".to_string()).into());
|
||||
}
|
||||
|
||||
// check metadata
|
||||
let metadata: EntryMetadata = match toml::from_str(&req.metadata) {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
|
||||
};
|
||||
|
||||
if let Err(e) = metadata.validate() {
|
||||
return Json(Error::MiscError(e.to_string()).into());
|
||||
}
|
||||
|
||||
// ...
|
||||
let (id, mut entry) = match data
|
||||
.query(&SimplifiedQuery {
|
||||
|
@ -418,6 +475,7 @@ async fn edit_request(
|
|||
|
||||
// update
|
||||
entry.content = req.content;
|
||||
entry.metadata = req.metadata;
|
||||
entry.edited = unix_epoch_timestamp();
|
||||
|
||||
if let Err(e) = data
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue