From 696b34f2f17ef2d86f0bc77993f9b0b8b652c0f6 Mon Sep 17 00:00:00 2001 From: JMARyA Date: Sun, 23 Feb 2025 22:52:19 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20ogp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- src/lib.rs | 1 + src/ogp.rs | 656 +++++++++++++++++++++++++++++++++++++ src/ui/components/shell.rs | 11 +- 4 files changed, 669 insertions(+), 2 deletions(-) create mode 100644 src/ogp.rs diff --git a/.gitignore b/.gitignore index 5707720..c924ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ src/htmx.min.js src/flowbite.min.css src/flowbite.min.js -src/material.woff2 \ No newline at end of file +src/material.woff2 +/examples/test.rs \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index deb10f9..6255764 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ use tokio::sync::OnceCell; pub mod asset; pub mod auth; pub mod format; +pub mod ogp; pub mod request; pub mod result; pub mod ui; diff --git a/src/ogp.rs b/src/ogp.rs new file mode 100644 index 0000000..95d4da2 --- /dev/null +++ b/src/ogp.rs @@ -0,0 +1,656 @@ +use maud::{PreEscaped, html}; + +use crate::ui::prelude::Optional; + +// TODO : documentation + +#[allow(non_snake_case)] +pub fn Meta(key: &str, value: &str) -> PreEscaped { + html! { + meta property=(key) content=(value) {}; + } +} + +#[derive(Debug, Clone)] +pub struct Metadata { + pub title: String, + pub kind: PreEscaped, + pub image: MediaItem, + pub url: String, + + pub audio: Option, + pub description: Option, + pub determiner: Option, + pub locale: Option, + pub locale_alternate: Vec, + pub site_name: Option, + pub video: Option, +} + +impl Metadata { + pub fn new(url: &str, title: &str, image: MediaItem, kind: T) -> Self { + Self { + title: title.to_string(), + kind: kind.render(), + image, + url: url.to_string(), + audio: None, + description: None, + determiner: None, + locale: None, + locale_alternate: Vec::new(), + site_name: None, + video: None, + } + } + + pub fn audio(mut self, audio: MediaItem) -> Self { + self.audio = Some(audio); + self + } + + pub fn description(mut self, description: &str) -> Self { + self.description = Some(description.to_string()); + self + } + + pub fn determiner(mut self, determiner: Determiner) -> Self { + self.determiner = Some(determiner); + self + } + + pub fn locale(mut self, locale: &str) -> Self { + self.locale = Some(locale.to_string()); + self + } + + pub fn locale_alternate(mut self, locale: &str) -> Self { + self.locale_alternate.push(locale.to_string()); + self + } + + pub fn site_name(mut self, site_name: &str) -> Self { + self.site_name = Some(site_name.to_string()); + self + } + + pub fn video(mut self, video: MediaItem) -> Self { + self.video = Some(video); + self + } +} + +fn array_meta(v: &[String], tag_name: &str) -> PreEscaped { + let r = v.into_iter().map(|x| Meta(tag_name, x)).collect::>(); + html! { + @for e in r { + (e) + } + } +} + +#[derive(Debug, Clone)] +pub enum Determiner { + A, + An, + The, + Blank, + Auto, +} + +impl Determiner { + pub fn render(&self) -> PreEscaped { + match self { + Determiner::A => Meta("og:determiner", "a"), + Determiner::An => Meta("og:determiner", "an"), + Determiner::The => Meta("og:determiner", "the"), + Determiner::Blank => Meta("og:determiner", ""), + Determiner::Auto => Meta("og:determiner", "auto"), + } + } +} + +impl Metadata { + pub fn render(&self) -> PreEscaped { + html! { + (Meta("og:title", &self.title)) + (Optional(self.description.as_ref(), |desc| Meta("og:description", desc))) + (Optional(self.site_name.as_ref(), |site_name| Meta("og:site_name", site_name))) + (Optional(self.determiner.as_ref(), |determiner| determiner.render())) + (Optional(self.locale.as_ref(), |locale| Meta("og:locale", locale))) + (array_meta(&self.locale_alternate, "og:locale:alternate")) + (Meta("og:url", &self.url)) + (self.image.render()) + (Optional(self.video.as_ref(), |video| video.render())) + (Optional(self.audio.as_ref(), |audio| audio.render())) + (self.kind) + } + } +} + +#[derive(Debug, Clone)] +pub enum MediaItemKind { + Image, + Video, + Audio, +} + +#[derive(Debug, Clone)] +pub struct MediaItem { + pub url: String, + pub secure_url: Option, + pub mime: String, + pub width: Option, + pub height: Option, + pub alt: String, + pub kind: MediaItemKind, +} + +impl MediaItem { + pub fn secure_url(mut self, url: &str) -> Self { + self.secure_url = Some(url.to_string()); + self + } + + pub fn width(mut self, width: u32) -> Self { + self.width = Some(width); + self + } + + pub fn height(mut self, height: u32) -> Self { + self.height = Some(height); + self + } +} + +impl MediaItem { + #[allow(non_snake_case)] + pub fn Image(url: &str, mime: &str, alt: &str) -> Self { + let secure_url = if url.starts_with("https") { + Some(url.to_string()) + } else { + None + }; + + MediaItem { + url: url.to_string(), + secure_url: secure_url, + mime: mime.to_string(), + width: None, + height: None, + alt: alt.to_string(), + kind: MediaItemKind::Image, + } + } + + #[allow(non_snake_case)] + pub fn Video(url: &str, mime: &str, alt: &str) -> Self { + let secure_url = if url.starts_with("https") { + Some(url.to_string()) + } else { + None + }; + + MediaItem { + url: url.to_string(), + secure_url: secure_url, + mime: mime.to_string(), + width: None, + height: None, + alt: alt.to_string(), + kind: MediaItemKind::Video, + } + } + + #[allow(non_snake_case)] + pub fn Audio(url: &str, mime: &str, alt: &str) -> Self { + let secure_url = if url.starts_with("https") { + Some(url.to_string()) + } else { + None + }; + + MediaItem { + url: url.to_string(), + secure_url: secure_url, + mime: mime.to_string(), + width: None, + height: None, + alt: alt.to_string(), + kind: MediaItemKind::Audio, + } + } +} + +impl MediaItem { + pub fn render(&self) -> PreEscaped { + match self.kind { + MediaItemKind::Image => { + html! { + (Meta("og:image", &self.url)) + (Optional(self.secure_url.as_ref(), |secure_url| Meta("og:image:secure_url", secure_url))) + (Meta("og:image:type", &self.mime)) + (Optional(self.width.as_ref(), |width| Meta("og:image:width", &width.to_string()))) + (Optional(self.height.as_ref(), |height| Meta("og:image:height", &height.to_string()))) + (Meta("og:image:alt", &self.alt)) + } + } + MediaItemKind::Video => { + html! { + (Meta("og:video", &self.url)) + (Optional(self.secure_url.as_ref(), |secure_url| Meta("og:video:secure_url", secure_url))) + (Meta("og:video:type", &self.mime)) + (Optional(self.width.as_ref(), |width| Meta("og:video:width", &width.to_string()))) + (Optional(self.height.as_ref(), |height| Meta("og:video:height", &height.to_string()))) + } + } + MediaItemKind::Audio => { + html! { + (Meta("og:audio", &self.url)) + (Optional(self.secure_url.as_ref(), |secure_url| Meta("og:audio:secure_url", secure_url))) + (Meta("og:audio:type", &self.mime)) + } + } + } + } +} + +pub struct Song { + pub duration: u32, + pub album: String, + pub album_disc: u32, + pub album_track: u32, + pub musician: Vec, +} + +impl Song { + pub fn new(duration: u32, album: &str, album_disc: u32, album_track: u32) -> Self { + Self { + duration, + album: album.to_string(), + album_disc, + album_track, + musician: Vec::new(), + } + } + + pub fn musician(mut self, musician: &str) -> Self { + self.musician.push(musician.to_string()); + self + } +} + +impl ObjectType for Song { + fn render(&self) -> PreEscaped { + html! { + (Meta("og:type", "music.song")) + (Meta("music:duration", &self.duration.to_string())) + (Meta("music:album:disc", &self.album_disc.to_string())) + (Meta("music:album:track", &self.album_track.to_string())) + (array_meta(&self.musician, "music:musician")) + } + } +} + +pub struct Album { + pub song: Vec<(String, u32, u32)>, + pub musician: String, + pub release_date: chrono::NaiveDate, +} + +impl Album { + pub fn new(musician: &str, release_date: chrono::NaiveDate) -> Self { + Self { + song: Vec::new(), + musician: musician.to_string(), + release_date, + } + } + + pub fn song(mut self, song: &str, disc: u32, track: u32) -> Self { + self.song.push((song.to_string(), disc, track)); + self + } +} + +impl ObjectType for Album { + fn render(&self) -> PreEscaped { + html! { + @for song in &self.song { + (Meta("music:song", &song.0)) + (Meta("music:song:disc", &song.1.to_string())) + (Meta("music:song:track", &song.2.to_string())) + } + (Meta("music:musician", &self.musician)) + (Meta("music:release_date", &self.release_date.format("%Y-%m-%d").to_string())) + } + } +} + +pub struct Playlist { + pub song: Vec<(String, u32, u32)>, + pub creator: String, +} + +impl Playlist { + pub fn new(creator: &str) -> Self { + Self { + song: Vec::new(), + creator: creator.to_string(), + } + } + + pub fn song(mut self, song: &str, disc: u32, track: u32) -> Self { + self.song.push((song.to_string(), disc, track)); + self + } +} + +impl ObjectType for Playlist { + fn render(&self) -> PreEscaped { + html! { + @for song in &self.song { + (Meta("music:song", &song.0)) + (Meta("music:song:disc", &song.1.to_string())) + (Meta("music:song:track", &song.2.to_string())) + } + (Meta("music:creator", &self.creator)) + } + } +} + +pub struct RadioStation { + pub creator: String, +} + +impl RadioStation { + pub fn new(creator: &str) -> Self { + Self { + creator: creator.to_string(), + } + } +} + +impl ObjectType for RadioStation { + fn render(&self) -> PreEscaped { + html! { + (Meta("music:creator", &self.creator)) + } + } +} + +pub struct Video { + pub actor: Vec<(String, String)>, + pub director: Vec, + pub writer: Vec, + pub duration: u32, + pub release_date: chrono::NaiveDate, + pub tag: Vec, + pub series: Option, + pub kind: VideoType, +} + +impl Video { + pub fn actor(mut self, actor: &str, role: &str) -> Self { + self.actor.push((actor.to_string(), role.to_string())); + self + } + + pub fn director(mut self, director: &str) -> Self { + self.director.push(director.to_string()); + self + } + + pub fn writer(mut self, writer: &str) -> Self { + self.writer.push(writer.to_string()); + self + } + + pub fn tag(mut self, tag: &str) -> Self { + self.tag.push(tag.to_string()); + self + } +} + +impl Video { + #[allow(non_snake_case)] + pub fn Movie(duration: u32, release_date: chrono::NaiveDate) -> Self { + Self { + actor: Vec::new(), + director: Vec::new(), + writer: Vec::new(), + duration, + release_date, + tag: Vec::new(), + series: None, + kind: VideoType::Movie, + } + } + + #[allow(non_snake_case)] + pub fn Episode(duration: u32, release_date: chrono::NaiveDate) -> Self { + Self { + actor: Vec::new(), + director: Vec::new(), + writer: Vec::new(), + duration, + release_date, + tag: Vec::new(), + series: None, + kind: VideoType::Episode, + } + } + + #[allow(non_snake_case)] + pub fn TV_Show(duration: u32, release_date: chrono::NaiveDate, series: &str) -> Self { + Self { + actor: Vec::new(), + director: Vec::new(), + writer: Vec::new(), + duration, + release_date, + tag: Vec::new(), + series: Some(series.to_string()), + kind: VideoType::TV_Show, + } + } + + #[allow(non_snake_case)] + pub fn Other(duration: u32, release_date: chrono::NaiveDate) -> Self { + Self { + actor: Vec::new(), + director: Vec::new(), + writer: Vec::new(), + duration, + release_date, + tag: Vec::new(), + series: None, + kind: VideoType::Other, + } + } +} + +impl ObjectType for Video { + fn render(&self) -> PreEscaped { + html! { + (Meta("og:type", self.kind.kind())) + @for actor in &self.actor { + (Meta("video:actor", &actor.0)) + (Meta("video:actor:role", &actor.1)) + } + (array_meta(&self.director, "video:director")) + (array_meta(&self.writer, "video:writer")) + (Meta("video:duration", &self.duration.to_string())) + (Meta("video:release_date", &self.release_date.format("%Y-%m-%d").to_string())) + (array_meta(&self.tag, "video:tag")) + (Optional(self.series.as_ref(), |series| Meta("video.series", series))) + } + } +} + +pub enum VideoType { + Movie, + Episode, + #[allow(non_camel_case_types)] + TV_Show, + Other, +} + +impl VideoType { + pub fn kind(&self) -> &str { + match self { + VideoType::Movie => "video.movie", + VideoType::Episode => "video.episode", + VideoType::TV_Show => "video.tv_show", + VideoType::Other => "video.other", + } + } +} + +pub struct Article { + pub published_time: chrono::NaiveDate, + pub modified_time: chrono::NaiveDate, + pub expiration_time: chrono::NaiveDate, + pub author: Vec, + pub section: String, + pub tag: Vec, +} + +impl Article { + pub fn new( + published_time: chrono::NaiveDate, + modified_time: chrono::NaiveDate, + expiration_time: chrono::NaiveDate, + section: &str, + ) -> Self { + Self { + published_time, + modified_time, + expiration_time, + author: Vec::new(), + section: section.to_string(), + tag: Vec::new(), + } + } + + pub fn author(mut self, author: &str) -> Self { + self.author.push(author.to_string()); + self + } + + pub fn tag(mut self, tag: &str) -> Self { + self.tag.push(tag.to_string()); + self + } +} + +impl ObjectType for Article { + fn render(&self) -> PreEscaped { + html! { + (Meta("article:published_time", &self.published_time.format("%Y-%m-%d").to_string())) + (Meta("article:modified_time", &self.modified_time.format("%Y-%m-%d").to_string())) + (Meta("article:expiration_time", &self.expiration_time.format("%Y-%m-%d").to_string())) + (array_meta(&self.author, "article:author")) + (Meta("article:section", &self.section)) + (array_meta(&self.tag, "article:tag")) + } + } +} + +pub struct Book { + pub author: Vec, + pub isbn: String, + pub release_date: chrono::NaiveDate, + pub tag: Vec, +} + +impl Book { + pub fn new(isbn: &str, release_date: chrono::NaiveDate) -> Self { + Self { + author: Vec::new(), + isbn: isbn.to_string(), + release_date, + tag: Vec::new(), + } + } + + pub fn author(mut self, author: &str) -> Self { + self.author.push(author.to_string()); + self + } + + pub fn tag(mut self, tag: &str) -> Self { + self.tag.push(tag.to_string()); + self + } +} + +impl ObjectType for Book { + fn render(&self) -> PreEscaped { + html! { + (array_meta(&self.author, "book:author")) + (Meta("book:isbn", &self.isbn)) + (Meta("book:release_date", &self.release_date.format("%Y-%m-%d").to_string())) + (array_meta(&self.tag, "book:tag")) + } + } +} + +pub struct Profile { + first_name: String, + last_name: String, + username: String, + gender: Gender, +} + +impl Profile { + pub fn new(first_name: &str, last_name: &str, username: &str, gender: Gender) -> Self { + Self { + first_name: first_name.to_string(), + last_name: last_name.to_string(), + username: username.to_string(), + gender, + } + } +} + +pub enum Gender { + Male, + Female, + None, +} + +impl ObjectType for Profile { + fn render(&self) -> PreEscaped { + html! { + (Meta("profile:first_name", &self.first_name)) + (Meta("profile:last_name", &self.last_name)) + (Meta("profile:username", &self.username)) + (Meta("profile:gender", match self.gender { + Gender::Male => "male", + Gender::Female => "female", + Gender::None => "none", + })) + } + } +} + +pub struct Website; + +impl Website { + pub fn new() -> Self { + Self + } +} + +impl ObjectType for Website { + fn render(&self) -> PreEscaped { + html! { + (Meta("og:type", "website")) + } + } +} + +pub trait ObjectType { + fn render(&self) -> PreEscaped; +} diff --git a/src/ui/components/shell.rs b/src/ui/components/shell.rs index ed5fd23..aac9ab7 100644 --- a/src/ui/components/shell.rs +++ b/src/ui/components/shell.rs @@ -3,12 +3,13 @@ use std::sync::Arc; use maud::{PreEscaped, Render, html}; use crate::{ + ogp::Metadata, request::{RequestContext, StringResponse}, ui::{ UIWidget, color::{Gray, UIColor}, htmx::{Event, HTMXAttributes, Selector, SwapStrategy}, - prelude::{Div, Link}, + prelude::{Div, Link, Optional}, primitives::link::LinkWidget, }, }; @@ -25,6 +26,7 @@ pub struct Shell { /// The HTML content for the static body portion. body_content: Arc>, ui: bool, + metadata: Option, bottom_nav: Option>>, sidebar: Option>, navbar: Option>, @@ -51,6 +53,7 @@ impl Shell { main_class: Arc::new(body_class.extended_class().join(" ")), body_content: Arc::new(body_content.render()), ui: false, + metadata: None, bottom_nav: None, sidebar: None, navbar: None, @@ -69,6 +72,11 @@ impl Shell { self.clone() } + pub fn metadata(mut self, metadata: Metadata) -> Self { + self.metadata = Some(metadata); + self + } + pub fn with_navbar(mut self, navbar: NavBarWidget) -> Self { self.navbar = Some(Arc::new(navbar)); self @@ -123,6 +131,7 @@ impl Shell { meta name="viewport" content="width=device-width, initial-scale=1.0"; }; (self.head) + (Optional(self.metadata.as_ref(), |meta| meta.render())) };