Compare commits

..

1 commit

Author SHA1 Message Date
71e9776aa2 add raw post endpoint
All checks were successful
PR Check / lint-and-format (pull_request) Successful in 2m43s
2024-11-06 14:04:22 +01:00
32 changed files with 995 additions and 1198 deletions

13
.gitignore vendored
View file

@ -5,15 +5,4 @@
docker-compose.yml docker-compose.yml
/.vscode /.vscode
/data
# Data
data/index.md
data/contacts/*
data/cours/*
data/projects/*
# Blog
data/blog/*.md
data/blog/posts/*
!data/blog/posts/Makefile

5
Cargo.lock generated
View file

@ -557,9 +557,9 @@ dependencies = [
[[package]] [[package]]
name = "cached" name = "cached"
version = "0.54.0" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae" checksum = "b4d73155ae6b28cf5de4cfc29aeb02b8a1c6dab883cb015d15cd514e42766846"
dependencies = [ dependencies = [
"ahash 0.8.11", "ahash 0.8.11",
"async-trait", "async-trait",
@ -1095,7 +1095,6 @@ dependencies = [
"serde_yml", "serde_yml",
"toml", "toml",
"urlencoding", "urlencoding",
"walkdir",
] ]
[[package]] [[package]]

View file

@ -12,7 +12,7 @@ license = "AGPL-3.0-or-later"
[dependencies] [dependencies]
actix-web = { version = "4.9", default-features = false, features = ["macros", "compress-brotli"] } actix-web = { version = "4.9", default-features = false, features = ["macros", "compress-brotli"] }
actix-files = "0.6" actix-files = "0.6"
cached = { version = "0.54", features = ["async", "ahash"] } cached = { version = "0.53", features = ["async", "ahash"] }
ramhorns = "1.0" ramhorns = "1.0"
toml = "0.8" toml = "0.8"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
@ -32,11 +32,6 @@ mime_guess = "2.0"
urlencoding = "2.1" urlencoding = "2.1"
regex = "1.10" regex = "1.10"
cyborgtime = "2.1.1" cyborgtime = "2.1.1"
walkdir = "2.5"
[lints.clippy] [lints.clippy]
pedantic = "warn" pedantic = "warn"
[profile.release]
strip = "symbols"
lto = "thin"

View file

@ -171,7 +171,6 @@ title: Option<String>
date: Option<Date> date: Option<Date>
description: Option<String> description: Option<String>
publish: Option<bool> publish: Option<bool>
draft: Option<bool>
tags: Option<Vec<Tag>> tags: Option<Vec<Tag>>
--- ---
@ -181,8 +180,7 @@ Post content
- If no `title`, the filename will be used - If no `title`, the filename will be used
- `date` format is `day-month-year` - `date` format is `day-month-year`
- `publish` is default to false. When false, posts are hidden from index - `publish` is default to false. When false, posts are hidden from index
but accessible. but accessible, see [#30](https://git.mylloon.fr/Anri/mylloon.fr/issues/30)
- `draft` is default to false. When true, posts are hidden and unaccessible.
### About <!-- omit in toc --> ### About <!-- omit in toc -->
@ -225,7 +223,6 @@ custom: Option<bool>
user: "Option<String>" user: "Option<String>"
link: Option<String> link: Option<String>
newtab: Option<bool> newtab: Option<bool>
hide: Option<bool>
description: > description: >
Option<String> Option<String>
--- ---

View file

@ -1,20 +0,0 @@
MKDIR = mkdir -p
TOUCH = touch
PRINT = echo
DATE = $(shell date '+%d-%m-%Y')
DIR = $(shell date '+%Y/%m')
PH := new
new:
$(MKDIR) $(DIR) 2> /dev/null
$(TOUCH) $(DIR)/$(PH).md
$(PRINT) "---" > $(DIR)/$(PH).md
$(PRINT) "publish: false" >> $(DIR)/$(PH).md
$(PRINT) "date: $(DATE)" >> $(DIR)/$(PH).md
$(PRINT) "draft: true" >> $(DIR)/$(PH).md
$(PRINT) "---" >> $(DIR)/$(PH).md
help:
$(PRINT) "make PH=new"

View file

@ -13,8 +13,8 @@ use crate::routes::{
mod config; mod config;
mod template; mod template;
mod misc;
mod routes; mod routes;
mod utils;
#[actix_web::main] #[actix_web::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
@ -58,6 +58,7 @@ async fn main() -> Result<()> {
.service(blog::index) .service(blog::index)
.service(blog::rss) .service(blog::rss)
.service(blog::page) .service(blog::page)
.service(blog::rawpage)
.service(contrib::page) .service(contrib::page)
.service(cours::page) .service(cours::page)
.service(cv::page) .service(cv::page)

View file

@ -2,7 +2,7 @@ use chrono::{Datelike, NaiveDate};
use ramhorns::Content; use ramhorns::Content;
use serde::{Deserialize, Deserializer}; use serde::{Deserialize, Deserializer};
#[derive(Content, Clone, Default, Debug)] #[derive(Content, Default, Debug)]
pub struct Date { pub struct Date {
pub day: u32, pub day: u32,
pub month: u32, pub month: u32,

View file

@ -1,7 +1,7 @@
use reqwest::{header::ACCEPT, Error}; use reqwest::{header::ACCEPT, Error};
use serde::Deserialize; use serde::Deserialize;
use crate::utils::misc::get_reqwest_client; use crate::misc::utils::get_reqwest_client;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct GithubResponse { struct GithubResponse {

View file

@ -1,3 +1,4 @@
use crate::misc::date::Date;
use base64::engine::general_purpose; use base64::engine::general_purpose;
use base64::Engine; use base64::Engine;
use comrak::nodes::{AstNode, NodeValue}; use comrak::nodes::{AstNode, NodeValue};
@ -5,16 +6,114 @@ use comrak::{format_html, parse_document, Arena, ComrakOptions, ListStyleType, O
use lol_html::html_content::ContentType; use lol_html::html_content::ContentType;
use lol_html::{element, rewrite_str, HtmlRewriter, RewriteStrSettings, Settings}; use lol_html::{element, rewrite_str, HtmlRewriter, RewriteStrSettings, Settings};
use ramhorns::Content; use ramhorns::Content;
use serde::{Deserialize, Deserializer};
use std::fmt::Debug; use std::fmt::Debug;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use crate::utils::metadata::MType; /// Metadata for blog posts
#[derive(Content, Debug, Default, Deserialize)]
pub struct FileMetadataBlog {
pub title: Option<String>,
pub date: Option<Date>,
pub description: Option<String>,
pub publish: Option<bool>,
pub tags: Option<Vec<Tag>>,
pub toc: Option<bool>,
}
use super::metadata::{get, MFile, Metadata}; /// A tag, related to post blog
#[derive(Content, Debug, Clone)]
pub struct Tag {
pub name: String,
}
impl<'a> Deserialize<'a> for Tag {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'a>,
{
match <&str>::deserialize(deserializer) {
Ok(s) => match serde_yml::from_str(s) {
Ok(tag) => Ok(Self { name: tag }),
Err(e) => Err(serde::de::Error::custom(e)),
},
Err(e) => Err(e),
}
}
}
/// Metadata for contact entry
#[derive(Content, Debug, Default, Deserialize)]
pub struct FileMetadataContact {
pub title: String,
pub custom: Option<bool>,
pub user: Option<String>,
pub link: Option<String>,
pub newtab: Option<bool>,
pub description: Option<String>,
}
/// Metadata for index page
#[derive(Content, Debug, Default, Deserialize)]
pub struct FileMetadataIndex {
pub name: Option<String>,
pub pronouns: Option<String>,
pub avatar: Option<String>,
pub avatar_caption: Option<String>,
pub avatar_style: Option<String>,
}
/// Metadata for portfolio cards
#[derive(Content, Debug, Default, Deserialize)]
pub struct FileMetadataPortfolio {
pub title: Option<String>,
pub link: Option<String>,
pub description: Option<String>,
pub language: Option<String>,
}
/// List of available metadata types
pub enum TypeFileMetadata {
Blog,
Contact,
Generic,
Index,
Portfolio,
}
/// Structure who holds all the metadata the file have
/// Usually all fields are None except one
#[derive(Content, Debug, Default, Deserialize)]
pub struct FileMetadata {
pub blog: Option<FileMetadataBlog>,
pub contact: Option<FileMetadataContact>,
pub index: Option<FileMetadataIndex>,
pub portfolio: Option<FileMetadataPortfolio>,
}
#[allow(clippy::struct_excessive_bools)]
/// Global metadata
#[derive(Content, Debug)]
pub struct Metadata {
pub info: FileMetadata,
pub math: bool,
pub mermaid: bool,
pub syntax_highlight: bool,
pub mail_obfsucated: bool,
}
impl Metadata {
/// Update current metadata boolean fields, keeping true ones
fn merge(&mut self, other: &Self) {
self.math = self.math || other.math;
self.mermaid = self.mermaid || other.mermaid;
self.syntax_highlight = self.syntax_highlight || other.syntax_highlight;
}
}
/// File description /// File description
#[derive(Content, Debug, Clone)] #[derive(Content, Debug)]
pub struct File { pub struct File {
pub metadata: Metadata, pub metadata: Metadata,
pub content: String, pub content: String,
@ -52,7 +151,7 @@ pub fn get_options<'a>() -> ComrakOptions<'a> {
// options.render.broken_link_callback = ...; // options.render.broken_link_callback = ...;
// Renderer // Renderer
options.render.hardbreaks = false; options.render.hardbreaks = false; // could be true? change by metadata could be good for compatibility
options.render.github_pre_lang = false; options.render.github_pre_lang = false;
options.render.full_info_string = true; options.render.full_info_string = true;
options.render.width = 0; // 0 mean disabled? options.render.width = 0; // 0 mean disabled?
@ -122,7 +221,7 @@ fn custom_img_size(html: &str) -> String {
/// Fix local images to base64 and integration of markdown files /// Fix local images to base64 and integration of markdown files
fn fix_images_and_integration(path: &str, html: &str) -> (String, Metadata) { fn fix_images_and_integration(path: &str, html: &str) -> (String, Metadata) {
let mut metadata = Metadata { let mut metadata = Metadata {
info: MFile::default(), info: FileMetadata::default(),
math: false, math: false,
mermaid: false, mermaid: false,
syntax_highlight: false, syntax_highlight: false,
@ -146,7 +245,12 @@ fn fix_images_and_integration(path: &str, html: &str) -> (String, Metadata) {
if mime == "text/markdown" { if mime == "text/markdown" {
let mut options = get_options(); let mut options = get_options();
options.extension.footnotes = false; options.extension.footnotes = false;
let data = read_md(&img_path, &file, MType::Generic, Some(options)); let data = read_md(
&img_path,
&file,
&TypeFileMetadata::Generic,
Some(options),
);
el.replace(&data.content, ContentType::Html); el.replace(&data.content, ContentType::Html);
// Store the metadata for later merging // Store the metadata for later merging
@ -174,17 +278,19 @@ fn fix_images_and_integration(path: &str, html: &str) -> (String, Metadata) {
} }
/// Transform markdown string to File structure /// Transform markdown string to File structure
pub fn read_md(path: &str, raw_text: &str, metadata_type: MType, options: Option<Options>) -> File { pub fn read_md(
path: &str,
raw_text: &str,
metadata_type: &TypeFileMetadata,
options: Option<Options>,
) -> File {
let arena = Arena::new(); let arena = Arena::new();
let mut opt = options.map_or_else(get_options, |specific_opt| specific_opt); let opt = options.map_or_else(get_options, |specific_opt| specific_opt);
let root = parse_document(&arena, raw_text, &opt); let root = parse_document(&arena, raw_text, &opt);
// Find metadata // Find metadata
let metadata = get(root, metadata_type); let metadata = get_metadata(root, metadata_type);
// Update comrak render properties
opt.render.hardbreaks = metadata.hardbreaks;
let mermaid_name = "mermaid"; let mermaid_name = "mermaid";
hljs_replace(root, mermaid_name); hljs_replace(root, mermaid_name);
@ -216,6 +322,70 @@ pub fn read_md(path: &str, raw_text: &str, metadata_type: MType, options: Option
} }
} }
/// Deserialize metadata based on a type
fn deserialize_metadata<T: Default + serde::de::DeserializeOwned>(text: &str) -> T {
serde_yml::from_str(text.trim().trim_matches(&['-'] as &[_])).unwrap_or_default()
}
/// Fetch metadata from AST
pub fn get_metadata<'a>(root: &'a AstNode<'a>, mtype: &TypeFileMetadata) -> FileMetadata {
root.children()
.find_map(|node| match &node.data.borrow().value {
// Extract metadata from frontmatter
NodeValue::FrontMatter(text) => Some(match mtype {
TypeFileMetadata::Blog => FileMetadata {
blog: Some(deserialize_metadata(text)),
..FileMetadata::default()
},
TypeFileMetadata::Contact => {
let mut metadata: FileMetadataContact = deserialize_metadata(text);
// Trim descriptions
if let Some(desc) = &mut metadata.description {
desc.clone_from(&desc.trim().into());
}
FileMetadata {
contact: Some(metadata),
..FileMetadata::default()
}
}
TypeFileMetadata::Generic => FileMetadata::default(),
TypeFileMetadata::Index => FileMetadata {
index: Some(deserialize_metadata(text)),
..FileMetadata::default()
},
TypeFileMetadata::Portfolio => FileMetadata {
portfolio: Some(deserialize_metadata(text)),
..FileMetadata::default()
},
}),
_ => None,
})
.map_or_else(
|| match mtype {
TypeFileMetadata::Blog => FileMetadata {
blog: Some(FileMetadataBlog::default()),
..FileMetadata::default()
},
TypeFileMetadata::Contact => FileMetadata {
contact: Some(FileMetadataContact::default()),
..FileMetadata::default()
},
TypeFileMetadata::Generic => FileMetadata::default(),
TypeFileMetadata::Index => FileMetadata {
index: Some(FileMetadataIndex::default()),
..FileMetadata::default()
},
TypeFileMetadata::Portfolio => FileMetadata {
portfolio: Some(FileMetadataPortfolio::default()),
..FileMetadata::default()
},
},
|data| data,
)
}
/// Check whether mermaid diagrams are in the AST /// Check whether mermaid diagrams are in the AST
fn check_mermaid<'a>(root: &'a AstNode<'a>, mermaid_str: &str) -> bool { fn check_mermaid<'a>(root: &'a AstNode<'a>, mermaid_str: &str) -> bool {
root.children().any(|node| match &node.data.borrow().value { root.children().any(|node| match &node.data.borrow().value {

View file

@ -1,6 +1,4 @@
pub mod date; pub mod date;
pub mod github; pub mod github;
pub mod markdown; pub mod markdown;
pub mod metadata; pub mod utils;
pub mod misc;
pub mod routes;

View file

@ -11,10 +11,7 @@ use reqwest::Client;
use crate::config::FileConfiguration; use crate::config::FileConfiguration;
use super::{ use super::markdown::{read_md, File, FileMetadata, Metadata, TypeFileMetadata};
markdown::{read_md, File},
metadata::{MFile, MType, Metadata},
};
#[cached] #[cached]
pub fn get_reqwest_client() -> Client { pub fn get_reqwest_client() -> Client {
@ -56,14 +53,13 @@ impl Responder for Html {
} }
/// Read a file /// Read a file
#[cached] pub fn read_file(filename: &str, expected_file: &TypeFileMetadata) -> Option<File> {
pub fn read_file(filename: String, expected_file: MType) -> Option<File> { Path::new(filename)
Path::new(&filename.clone())
.extension() .extension()
.and_then(|ext| match ext.to_str().unwrap() { .and_then(|ext| match ext.to_str().unwrap() {
"pdf" => fs::read(filename).map_or(None, |bytes| Some(read_pdf(bytes))), "pdf" => fs::read(filename).map_or(None, |bytes| Some(read_pdf(bytes))),
_ => fs::read_to_string(&filename).map_or(None, |text| { _ => fs::read_to_string(filename).map_or(None, |text| {
Some(read_md(&filename, &text, expected_file, None)) Some(read_md(filename, &text, expected_file, None))
}), }),
}) })
} }
@ -73,7 +69,7 @@ fn read_pdf(data: Vec<u8>) -> File {
File { File {
metadata: Metadata { metadata: Metadata {
info: MFile::default(), info: FileMetadata::default(),
mermaid: false, mermaid: false,
syntax_highlight: false, syntax_highlight: false,
math: false, math: false,

View file

@ -1,4 +1,4 @@
use crate::{config::Config, template::InfosPage, utils::misc::get_url}; use crate::{config::Config, misc::utils::get_url, template::InfosPage};
use actix_web::{get, http::header::ContentType, routes, web, HttpResponse, Responder}; use actix_web::{get, http::header::ContentType, routes, web, HttpResponse, Responder};
use cached::proc_macro::once; use cached::proc_macro::once;
use ramhorns::Content; use ramhorns::Content;

View file

@ -1,18 +1,34 @@
use std::{
collections::hash_map::DefaultHasher,
fs,
hash::{Hash, Hasher},
};
use ::rss::{
extension::atom::{AtomExtension, Link},
Category, Channel, Guid, Image, Item,
};
use actix_web::{get, http::header::ContentType, routes, web, HttpResponse, Responder}; use actix_web::{get, http::header::ContentType, routes, web, HttpResponse, Responder};
use cached::proc_macro::once; use cached::proc_macro::once;
use chrono::{DateTime, Datelike, Local, NaiveDateTime, Utc};
use chrono_tz::Europe;
use comrak::{parse_document, Arena};
use ramhorns::Content; use ramhorns::Content;
use crate::{ use crate::{
config::Config, config::Config,
template::{InfosPage, NavBar}, misc::{
utils::{ date::Date,
markdown::File, markdown::{get_metadata, get_options, File, FileMetadataBlog, TypeFileMetadata},
metadata::MType, utils::{get_url, make_kw, read_file, Html},
misc::{make_kw, read_file, Html},
routes::blog::{build_rss, get_post, get_posts, Post, BLOG_DIR, MIME_TYPE_RSS, POST_DIR},
}, },
template::{InfosPage, NavBar},
}; };
const MIME_TYPE_RSS: &str = "application/rss+xml";
const BLOG_DIR: &str = "blog";
const POST_DIR: &str = "posts";
#[get("/blog")] #[get("/blog")]
pub async fn index(config: web::Data<Config>) -> impl Responder { pub async fn index(config: web::Data<Config>) -> impl Responder {
Html(build_index(config.get_ref().to_owned())) Html(build_index(config.get_ref().to_owned()))
@ -32,7 +48,8 @@ fn build_index(config: Config) -> String {
let mut posts = get_posts(&format!("{blog_dir}/{POST_DIR}")); let mut posts = get_posts(&format!("{blog_dir}/{POST_DIR}"));
// Get about // Get about
let about: Option<File> = read_file(format!("{blog_dir}/about.md"), MType::Generic); let about: Option<File> =
read_file(&format!("{blog_dir}/about.md"), &TypeFileMetadata::Generic);
// Sort from newest to oldest // Sort from newest to oldest
posts.sort_by_cached_key(|p| (p.date.year, p.date.month, p.date.day)); posts.sort_by_cached_key(|p| (p.date.year, p.date.month, p.date.day));
@ -60,6 +77,112 @@ fn build_index(config: Config) -> String {
) )
} }
#[derive(Content, Debug)]
struct Post {
title: String,
date: Date,
url: String,
desc: Option<String>,
content: Option<String>,
tags: Vec<String>,
}
impl Post {
// Fetch the file content
fn fetch_content(&mut self, data_dir: &str) {
let blog_dir = format!("{data_dir}/{BLOG_DIR}/{POST_DIR}");
let ext = ".md";
if let Some(file) = read_file(
&format!("{blog_dir}/{}{ext}", self.url),
&TypeFileMetadata::Blog,
) {
self.content = Some(file.content);
}
}
}
impl Hash for Post {
fn hash<H: Hasher>(&self, state: &mut H) {
if let Some(content) = &self.content {
content.hash(state);
}
}
}
fn get_posts(location: &str) -> Vec<Post> {
let entries = std::fs::read_dir(location).map_or_else(
|_| vec![],
|res| {
res.flatten()
.filter(|f| f.path().extension().map_or(false, |ext| ext == "md"))
.collect::<Vec<std::fs::DirEntry>>()
},
);
entries
.iter()
.filter_map(|f| {
let fname = f.file_name();
let filename = fname.to_string_lossy();
let file_without_ext = filename.split_at(filename.len() - 3).0;
let file_metadata = std::fs::read_to_string(format!("{location}/{filename}"))
.map_or_else(
|_| FileMetadataBlog {
title: Some(file_without_ext.into()),
..FileMetadataBlog::default()
},
|text| {
let arena = Arena::new();
let options = get_options();
let root = parse_document(&arena, &text, &options);
let mut metadata =
get_metadata(root, &TypeFileMetadata::Blog).blog.unwrap();
// Always have a title
metadata.title = metadata
.title
.map_or_else(|| Some(file_without_ext.into()), Some);
metadata
},
);
if file_metadata.publish == Some(true) {
Some(Post {
url: file_without_ext.into(),
title: file_metadata.title.unwrap(),
date: file_metadata.date.unwrap_or({
let m = f.metadata().unwrap();
let date = std::convert::Into::<DateTime<Utc>>::into(
m.modified().unwrap_or_else(|_| m.created().unwrap()),
)
.date_naive();
Date {
day: date.day(),
month: date.month(),
year: date.year(),
}
}),
desc: file_metadata.description,
content: None,
tags: file_metadata
.tags
.unwrap_or_default()
.iter()
.map(|t| t.name.clone())
.collect(),
})
} else {
None
}
})
.collect::<Vec<Post>>()
}
#[derive(Content, Debug)] #[derive(Content, Debug)]
struct BlogPostTemplate { struct BlogPostTemplate {
navbar: NavBar, navbar: NavBar,
@ -67,7 +190,7 @@ struct BlogPostTemplate {
toc: String, toc: String,
} }
#[get("/blog/p/{id:.*}")] #[get("/blog/p/{id}")]
pub async fn page(path: web::Path<(String,)>, config: web::Data<Config>) -> impl Responder { pub async fn page(path: web::Path<(String,)>, config: web::Data<Config>) -> impl Responder {
Html(build_post( Html(build_post(
&path.into_inner().0, &path.into_inner().0,
@ -75,6 +198,24 @@ pub async fn page(path: web::Path<(String,)>, config: web::Data<Config>) -> impl
)) ))
} }
#[get("/blog/raw/{id}")]
pub async fn rawpage(path: web::Path<(String,)>, config: web::Data<Config>) -> impl Responder {
let filename = format!(
"{}/{BLOG_DIR}/{POST_DIR}/{}.md",
config.locations.data_dir,
path.into_inner().0
);
match fs::read_to_string(filename) {
Ok(text) => HttpResponse::Ok()
.content_type(ContentType::plaintext())
.body(text),
Err(_) => HttpResponse::NotFound()
.content_type(ContentType::plaintext())
.body("post not found"),
}
}
fn build_post(file: &str, config: Config) -> String { fn build_post(file: &str, config: Config) -> String {
let mut post = None; let mut post = None;
let (infos, toc) = get_post( let (infos, toc) = get_post(
@ -98,6 +239,64 @@ fn build_post(file: &str, config: Config) -> String {
) )
} }
fn get_post(
post: &mut Option<File>,
filename: &str,
name: &str,
data_dir: &str,
) -> (InfosPage, String) {
let blog_dir = format!("{data_dir}/{BLOG_DIR}/{POST_DIR}");
let ext = ".md";
*post = read_file(
&format!("{blog_dir}/{filename}{ext}"),
&TypeFileMetadata::Blog,
);
let default = (
filename,
&format!("Blog d'{name}"),
Vec::new(),
String::new(),
);
let (title, desc, tags, toc) = match post {
Some(data) => (
match &data.metadata.info.blog.as_ref().unwrap().title {
Some(text) => text,
None => default.0,
},
match &data.metadata.info.blog.as_ref().unwrap().description {
Some(desc) => desc,
None => default.1,
},
match &data.metadata.info.blog.as_ref().unwrap().tags {
Some(tags) => tags.clone(),
None => default.2,
},
match &data.metadata.info.blog.as_ref().unwrap().toc {
// TODO: Generate TOC
Some(true) => String::new(),
_ => default.3,
},
),
None => default,
};
(
InfosPage {
title: Some(format!("Post: {title}")),
desc: Some(desc.clone()),
kw: Some(make_kw(
&["blog", "blogging", "write", "writing"]
.into_iter()
.chain(tags.iter().map(|t| t.name.as_str()))
.collect::<Vec<_>>(),
)),
},
toc,
)
}
#[routes] #[routes]
#[get("/blog/blog.rss")] #[get("/blog/blog.rss")]
#[get("/blog/rss")] #[get("/blog/rss")]
@ -106,3 +305,110 @@ pub async fn rss(config: web::Data<Config>) -> impl Responder {
.content_type(ContentType(MIME_TYPE_RSS.parse().unwrap())) .content_type(ContentType(MIME_TYPE_RSS.parse().unwrap()))
.body(build_rss(config.get_ref().to_owned())) .body(build_rss(config.get_ref().to_owned()))
} }
#[once(time = 10800)] // 3h
fn build_rss(config: Config) -> String {
let mut posts = get_posts(&format!(
"{}/{}/{}",
config.locations.data_dir, BLOG_DIR, POST_DIR
));
// Sort from newest to oldest
posts.sort_by_cached_key(|p| (p.date.year, p.date.month, p.date.day));
posts.reverse();
// Only the 20 newest
let max = 20;
if posts.len() > max {
posts.drain(max..);
}
let link_to_site = get_url(config.fc.clone());
let author = if let (Some(mail), Some(name)) = (config.fc.mail, config.fc.fullname.clone()) {
Some(format!("{mail} ({name})"))
} else {
None
};
let title = format!("Blog d'{}", config.fc.name.unwrap_or_default());
let lang = "fr";
let channel = Channel {
title: title.clone(),
link: link_to_site.clone(),
description: "Un fil qui parle d'informatique notamment".into(),
language: Some(lang.into()),
managing_editor: author.clone(),
webmaster: author,
pub_date: Some(Local::now().to_rfc2822()),
categories: ["blog", "blogging", "write", "writing"]
.iter()
.map(|&c| Category {
name: c.into(),
..Category::default()
})
.collect(),
generator: Some("ewp with rss crate".into()),
docs: Some("https://www.rssboard.org/rss-specification".into()),
image: Some(Image {
url: format!("{link_to_site}/icons/favicon-32x32.png"),
title: title.clone(),
link: link_to_site.clone(),
..Image::default()
}),
items: posts
.iter_mut()
.map(|p| {
// Get post data
p.fetch_content(&config.locations.data_dir);
// Build item
Item {
title: Some(p.title.clone()),
link: Some(format!("{}/blog/p/{}", link_to_site, p.url)),
description: p.content.clone(),
categories: p
.tags
.iter()
.map(|c| Category {
name: c.to_owned(),
..Category::default()
})
.collect(),
guid: Some(Guid {
value: format!("urn:hash:{}", {
let mut hasher = DefaultHasher::new();
p.hash(&mut hasher);
hasher.finish()
}),
permalink: false,
}),
pub_date: Some(
NaiveDateTime::parse_from_str(
&format!("{}-{}-{} 13:12:00", p.date.day, p.date.month, p.date.year),
"%d-%m-%Y %H:%M:%S",
)
.unwrap()
.and_local_timezone(Europe::Paris)
.unwrap()
.to_rfc2822(),
),
..Item::default()
}
})
.collect(),
atom_ext: Some(AtomExtension {
links: vec![Link {
href: format!("{link_to_site}/blog/rss"),
rel: "self".into(),
hreflang: Some(lang.into()),
mime_type: Some(MIME_TYPE_RSS.into()),
title: Some(title),
length: None,
}],
}),
..Channel::default()
};
std::str::from_utf8(&channel.write_to(Vec::new()).unwrap())
.unwrap()
.into()
}

View file

@ -1,16 +1,16 @@
use actix_web::{get, routes, web, HttpRequest, Responder}; use actix_web::{get, routes, web, HttpRequest, Responder};
use cached::proc_macro::once; use cached::proc_macro::once;
use glob::glob;
use ramhorns::Content; use ramhorns::Content;
use std::fs::read_to_string;
use crate::{ use crate::{
config::Config, config::Config,
template::{InfosPage, NavBar}, misc::{
utils::{ markdown::{File, TypeFileMetadata},
markdown::File, utils::{make_kw, read_file, Html},
metadata::MType,
misc::{make_kw, read_file, Html},
routes::contact::{find_links, read, remove_paragraphs},
}, },
template::{InfosPage, NavBar},
}; };
const CONTACT_DIR: &str = "contacts"; const CONTACT_DIR: &str = "contacts";
@ -32,6 +32,47 @@ async fn page(config: web::Data<Config>) -> impl Responder {
Html(build_page(config.get_ref().to_owned())) Html(build_page(config.get_ref().to_owned()))
} }
/// Contact node
#[derive(Clone, Debug)]
struct ContactLink {
service: String,
scope: Option<String>,
link: String,
}
#[once(time = 60)]
fn find_links(directory: String) -> Vec<ContactLink> {
// TOML filename
let toml_file = "links.toml";
// Read the TOML file and parse it
let toml_str = read_to_string(format!("{directory}/{toml_file}")).unwrap_or_default();
let mut redirections = vec![];
match toml::de::from_str::<toml::Value>(&toml_str) {
Ok(data) => {
if let Some(section) = data.as_table() {
section.iter().for_each(|(key, value)| {
// Scopes are delimited with `/`
let (service, scope) = match key.split_once('/') {
Some((service, scope)) => (service.to_owned(), Some(scope.to_owned())),
None => (key.to_owned(), None),
};
redirections.push(ContactLink {
service,
scope,
link: value.as_str().unwrap().to_owned(),
});
});
}
}
Err(_) => return vec![],
}
redirections
}
#[routes] #[routes]
#[get("/{service}")] #[get("/{service}")]
#[get("/{service}/{scope}")] #[get("/{service}/{scope}")]
@ -51,7 +92,7 @@ async fn service_redirection(config: web::Data<Config>, req: HttpRequest) -> imp
_ => false, _ => false,
}) })
// Returns the link // Returns the link
.map(|data| data.url.clone()) .map(|data| data.link.clone())
.collect::<Vec<String>>(); .collect::<Vec<String>>();
// This shouldn't be more than one link here // This shouldn't be more than one link here
@ -78,17 +119,39 @@ struct NetworksTemplate {
others: Vec<File>, others: Vec<File>,
} }
fn remove_paragraphs(list: &mut [File]) {
list.iter_mut()
.for_each(|file| file.content = file.content.replace("<p>", "").replace("</p>", ""));
}
#[once(time = 60)] #[once(time = 60)]
fn build_page(config: Config) -> String { fn build_page(config: Config) -> String {
let contacts_dir = format!("{}/{}", config.locations.data_dir, CONTACT_DIR); let contacts_dir = format!("{}/{}", config.locations.data_dir, CONTACT_DIR);
let ext = ".md"; let ext = ".md";
// Get about // Get about
let about = read_file(format!("{contacts_dir}/about.md"), MType::Generic); let about = read_file(
&format!("{contacts_dir}/about.md"),
&TypeFileMetadata::Generic,
);
let mut socials = read(&format!("{contacts_dir}/socials/*{ext}")); let socials_dir = "socials";
let mut forges = read(&format!("{contacts_dir}/forges/*{ext}")); let mut socials = glob(&format!("{contacts_dir}/{socials_dir}/*{ext}"))
let mut others = read(&format!("{contacts_dir}/others/*{ext}")); .unwrap()
.map(|e| read_file(&e.unwrap().to_string_lossy(), &TypeFileMetadata::Contact).unwrap())
.collect::<Vec<File>>();
let forges_dir = "forges";
let mut forges = glob(&format!("{contacts_dir}/{forges_dir}/*{ext}"))
.unwrap()
.map(|e| read_file(&e.unwrap().to_string_lossy(), &TypeFileMetadata::Contact).unwrap())
.collect::<Vec<File>>();
let others_dir = "others";
let mut others = glob(&format!("{contacts_dir}/{others_dir}/*{ext}"))
.unwrap()
.map(|e| read_file(&e.unwrap().to_string_lossy(), &TypeFileMetadata::Contact).unwrap())
.collect::<Vec<File>>();
// Remove paragraphs in custom statements // Remove paragraphs in custom statements
[&mut socials, &mut forges, &mut others] [&mut socials, &mut forges, &mut others]

View file

@ -1,10 +1,12 @@
use std::collections::HashMap;
use crate::{ use crate::{
config::Config, config::Config,
template::{InfosPage, NavBar}, misc::{
utils::{ github::{fetch_pr, ProjectState},
misc::{make_kw, Html}, utils::{make_kw, Html},
routes::contrib::{fetch, Project},
}, },
template::{InfosPage, NavBar},
}; };
use actix_web::{get, web, Responder}; use actix_web::{get, web, Responder};
use cached::proc_macro::once; use cached::proc_macro::once;
@ -24,6 +26,24 @@ struct PortfolioTemplate {
closed: Option<Vec<Project>>, closed: Option<Vec<Project>>,
} }
#[derive(Clone, Content, Debug)]
struct Project {
name: String,
url: String,
pulls_merged: Vec<Pull>,
pulls_open: Vec<Pull>,
pulls_closed: Vec<Pull>,
}
#[derive(Clone, Content, Debug)]
struct Pull {
url: String,
id: u32,
name_repo: String,
title: String,
state: u8,
}
#[once(time = 600)] // 10min #[once(time = 600)] // 10min
async fn build_page(config: Config) -> String { async fn build_page(config: Config) -> String {
let navbar = NavBar { let navbar = NavBar {
@ -32,8 +52,66 @@ async fn build_page(config: Config) -> String {
}; };
// Fetch latest data from github // Fetch latest data from github
let data = match fetch().await { let data = match fetch_pr().await {
Ok(data) => PortfolioTemplate { Ok(projects) => {
let mut data: Vec<Project> = Vec::new();
// Grouping PRs by projects
let mut map: HashMap<&str, Vec<Pull>> = HashMap::new();
for p in &projects {
let project = Pull {
url: p.contrib_url.clone(),
id: p.id,
name_repo: p.name.clone(),
title: p.title.clone(),
state: p.status as u8,
};
let project_name = p.name.as_str();
if map.contains_key(project_name) {
map.entry(project_name).and_modify(|v| v.push(project));
} else {
data.push(Project {
name: project_name.into(),
url: p.url.clone(),
pulls_merged: Vec::new(),
pulls_closed: Vec::new(),
pulls_open: Vec::new(),
});
map.insert(project_name, vec![project]);
}
}
// Distributes each PR in the right vector
for d in &mut data {
map.get(d.name.as_str()).unwrap().iter().for_each(|p| {
let state = p.state.try_into().unwrap();
match state {
ProjectState::Closed => d.pulls_closed.push(p.to_owned()),
ProjectState::Merged => d.pulls_merged.push(p.to_owned()),
ProjectState::Open => d.pulls_open.push(p.to_owned()),
}
});
let mut name: Vec<char> = d.name.replace('-', " ").chars().collect();
name[0] = name[0].to_uppercase().next().unwrap();
d.name = name.into_iter().collect();
}
// Ascending order by pulls IDs
for d in &mut data {
d.pulls_closed.reverse();
d.pulls_merged.reverse();
d.pulls_open.reverse();
}
// Ascending order by number of pulls
data.sort_by(|a, b| {
b.pulls_merged
.len()
.partial_cmp(&a.pulls_merged.len())
.unwrap()
});
PortfolioTemplate {
navbar, navbar,
error: false, error: false,
projects: Some( projects: Some(
@ -54,7 +132,8 @@ async fn build_page(config: Config) -> String {
.cloned() .cloned()
.collect(), .collect(),
), ),
}, }
}
Err(e) => { Err(e) => {
eprintln!("{e}"); eprintln!("{e}");

View file

@ -1,18 +1,18 @@
use std::path::Path;
use actix_web::{get, web, Responder}; use actix_web::{get, web, Responder};
use cached::proc_macro::cached; use cached::proc_macro::cached;
use ramhorns::Content; use ramhorns::Content;
use regex::Regex; use regex::Regex;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
config::Config, config::Config,
template::{InfosPage, NavBar}, misc::{
utils::{ markdown::{File, TypeFileMetadata},
markdown::File, utils::{make_kw, read_file, Html},
metadata::MType,
misc::{make_kw, read_file, Html},
routes::cours::{excluded, get_filetree},
}, },
template::{InfosPage, NavBar},
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -32,6 +32,13 @@ struct CoursTemplate {
content: Option<File>, content: Option<File>,
} }
#[derive(Clone, Debug, Serialize)]
struct FileNode {
name: String,
is_dir: bool,
children: Vec<FileNode>,
}
#[cached] #[cached]
fn compile_patterns(exclusion_list: Vec<String>) -> Vec<Regex> { fn compile_patterns(exclusion_list: Vec<String>) -> Vec<Regex> {
exclusion_list exclusion_list
@ -40,42 +47,76 @@ fn compile_patterns(exclusion_list: Vec<String>) -> Vec<Regex> {
.collect() .collect()
} }
fn get_filetree(dir_path: &str, exclusion_patterns: &Vec<Regex>) -> FileNode {
let children = std::fs::read_dir(dir_path)
.unwrap()
.filter_map(Result::ok)
.filter_map(|entry| {
let entry_path = entry.path();
let entry_name = entry_path.file_name()?.to_string_lossy().to_string();
// Exclude element with the exclusion_list
if exclusion_patterns.iter().any(|re| re.is_match(&entry_name)) {
return None;
}
if entry_path.is_file() {
Some(FileNode {
name: entry_name,
is_dir: false,
children: vec![],
})
} else {
// Exclude empty directories
let children_of_children =
get_filetree(entry_path.to_str().unwrap(), exclusion_patterns);
if children_of_children.is_dir && children_of_children.children.is_empty() {
None
} else {
Some(children_of_children)
}
}
})
.collect();
FileNode {
name: Path::new(dir_path)
.file_name()
.unwrap()
.to_string_lossy()
.to_string(),
is_dir: true,
children,
}
}
/// Get a page content /// Get a page content
fn get_content( fn get_content(
cours_dir: &str, cours_dir: &str,
path: &web::Query<PathRequest>, path: &web::Query<PathRequest>,
exclusion_list: &[String], exclusion_list: &[String],
exclusion_patterns: &[Regex],
) -> Option<File> { ) -> Option<File> {
let filename = path.q.as_ref().map_or("index.md", |q| q); let filename = path.q.as_ref().map_or("index.md", |q| q);
// Exclusion checks // We should support regex?
if excluded(filename, exclusion_list, exclusion_patterns) { if exclusion_list
.iter()
.any(|excluded_term| filename.contains(excluded_term.as_str()))
{
return None; return None;
} }
read_file(format!("{cours_dir}/{filename}"), MType::Generic) read_file(
&format!("{cours_dir}/{filename}"),
&TypeFileMetadata::Generic,
)
} }
fn build_page(info: &web::Query<PathRequest>, config: Config) -> String { fn build_page(info: &web::Query<PathRequest>, config: Config) -> String {
let cours_dir = "data/cours"; let cours_dir = "data/cours";
let exclusion_list = config.fc.exclude_courses.unwrap();
let (ep, el): (_, Vec<String>) = config let exclusion_patterns = compile_patterns(exclusion_list.clone());
.fc let filetree = get_filetree(cours_dir, &exclusion_patterns);
.exclude_courses
.unwrap()
.into_iter()
.partition(|item| item.starts_with('/'));
let exclusion_list = {
let mut base = vec!["../".to_owned()];
base.extend(el);
base
};
let exclusion_patterns: Vec<Regex> =
compile_patterns(ep.iter().map(|r| r[1..r.len() - 1].to_owned()).collect());
let filetree = get_filetree(cours_dir, &exclusion_list, &exclusion_patterns);
config.tmpl.render( config.tmpl.render(
"cours.html", "cours.html",
@ -85,7 +126,7 @@ fn build_page(info: &web::Query<PathRequest>, config: Config) -> String {
..NavBar::default() ..NavBar::default()
}, },
filetree: serde_json::to_string(&filetree).unwrap(), filetree: serde_json::to_string(&filetree).unwrap(),
content: get_content(cours_dir, info, &exclusion_list, &exclusion_patterns), content: get_content(cours_dir, info, &exclusion_list),
}, },
InfosPage { InfosPage {
title: Some("Cours".into()), title: Some("Cours".into()),

View file

@ -4,12 +4,11 @@ use ramhorns::Content;
use crate::{ use crate::{
config::Config, config::Config,
template::{InfosPage, NavBar}, misc::{
utils::{ markdown::{File, TypeFileMetadata},
markdown::File, utils::{make_kw, read_file, Html},
metadata::MType,
misc::{make_kw, read_file, Html},
}, },
template::{InfosPage, NavBar},
}; };
#[get("/")] #[get("/")]
@ -37,8 +36,8 @@ struct StyleAvatar {
#[once(time = 60)] #[once(time = 60)]
fn build_page(config: Config) -> String { fn build_page(config: Config) -> String {
let mut file = read_file( let mut file = read_file(
format!("{}/index.md", config.locations.data_dir), &format!("{}/index.md", config.locations.data_dir),
MType::Index, &TypeFileMetadata::Index,
); );
// Default values // Default values
@ -68,7 +67,7 @@ fn build_page(config: Config) -> String {
} }
} }
} else { } else {
file = read_file("README.md".to_string(), MType::Generic); file = read_file("README.md", &TypeFileMetadata::Generic);
} }
config.tmpl.render( config.tmpl.render(

View file

@ -4,8 +4,8 @@ use ramhorns::Content;
use crate::{ use crate::{
config::Config, config::Config,
misc::utils::{get_url, Html},
template::{InfosPage, NavBar}, template::{InfosPage, NavBar},
utils::misc::{get_url, Html},
}; };
pub async fn page(config: web::Data<Config>) -> impl Responder { pub async fn page(config: web::Data<Config>) -> impl Responder {

View file

@ -5,12 +5,11 @@ use ramhorns::Content;
use crate::{ use crate::{
config::Config, config::Config,
template::{InfosPage, NavBar}, misc::{
utils::{ markdown::{File, TypeFileMetadata},
markdown::File, utils::{make_kw, read_file, Html},
metadata::MType,
misc::{make_kw, read_file, Html},
}, },
template::{InfosPage, NavBar},
}; };
#[get("/portfolio")] #[get("/portfolio")]
@ -36,12 +35,15 @@ fn build_page(config: Config) -> String {
let ext = ".md"; let ext = ".md";
// Get about // Get about
let about = read_file(format!("{projects_dir}/about.md"), MType::Generic); let about = read_file(
&format!("{projects_dir}/about.md"),
&TypeFileMetadata::Generic,
);
// Get apps // Get apps
let apps = glob(&format!("{apps_dir}/*{ext}")) let apps = glob(&format!("{apps_dir}/*{ext}"))
.unwrap() .unwrap()
.map(|e| read_file(e.unwrap().to_string_lossy().to_string(), MType::Portfolio).unwrap()) .map(|e| read_file(&e.unwrap().to_string_lossy(), &TypeFileMetadata::Portfolio).unwrap())
.collect::<Vec<File>>(); .collect::<Vec<File>>();
let appdata = if apps.is_empty() { let appdata = if apps.is_empty() {
@ -53,7 +55,7 @@ fn build_page(config: Config) -> String {
// Get archived apps // Get archived apps
let archived_apps = glob(&format!("{apps_dir}/archive/*{ext}")) let archived_apps = glob(&format!("{apps_dir}/archive/*{ext}"))
.unwrap() .unwrap()
.map(|e| read_file(e.unwrap().to_string_lossy().to_string(), MType::Portfolio).unwrap()) .map(|e| read_file(&e.unwrap().to_string_lossy(), &TypeFileMetadata::Portfolio).unwrap())
.collect::<Vec<File>>(); .collect::<Vec<File>>();
let archived_appdata = if archived_apps.is_empty() { let archived_appdata = if archived_apps.is_empty() {

View file

@ -3,8 +3,8 @@ use cached::proc_macro::once;
use crate::{ use crate::{
config::Config, config::Config,
misc::utils::{make_kw, Html},
template::InfosPage, template::InfosPage,
utils::misc::{make_kw, Html},
}; };
#[get("/web3")] #[get("/web3")]

View file

@ -1,163 +0,0 @@
use crate::utils::date::Date;
use comrak::nodes::{AstNode, NodeValue};
use ramhorns::Content;
use serde::{Deserialize, Deserializer};
use std::fmt::Debug;
/// Metadata for blog posts
#[derive(Content, Clone, Debug, Default, Deserialize)]
pub struct FileMetadataBlog {
pub hardbreaks: Option<bool>,
pub title: Option<String>,
pub date: Option<Date>,
pub description: Option<String>,
pub publish: Option<bool>,
pub draft: Option<bool>,
pub tags: Option<Vec<Tag>>,
pub toc: Option<bool>,
}
/// A tag, related to post blog
#[derive(Content, Debug, Clone)]
pub struct Tag {
pub name: String,
}
impl<'a> Deserialize<'a> for Tag {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'a>,
{
match <&str>::deserialize(deserializer) {
Ok(s) => match serde_yml::from_str(s) {
Ok(tag) => Ok(Self { name: tag }),
Err(e) => Err(serde::de::Error::custom(e)),
},
Err(e) => Err(e),
}
}
}
/// Metadata for contact entry
#[derive(Content, Debug, Default, Deserialize, Clone)]
pub struct FileMetadataContact {
pub title: String,
pub custom: Option<bool>,
pub user: Option<String>,
pub link: Option<String>,
pub newtab: Option<bool>,
pub description: Option<String>,
pub hide: Option<bool>,
}
/// Metadata for index page
#[derive(Content, Debug, Default, Deserialize, Clone)]
pub struct FileMetadataIndex {
pub name: Option<String>,
pub pronouns: Option<String>,
pub avatar: Option<String>,
pub avatar_caption: Option<String>,
pub avatar_style: Option<String>,
}
/// Metadata for portfolio cards
#[derive(Content, Debug, Default, Deserialize, Clone)]
pub struct FileMetadataPortfolio {
pub title: Option<String>,
pub link: Option<String>,
pub description: Option<String>,
pub language: Option<String>,
}
/// List of available metadata types
#[derive(Hash, PartialEq, Eq, Clone, Copy)]
pub enum MType {
Blog,
Contact,
Generic,
Index,
Portfolio,
}
/// Structure who holds all the metadata the file have
/// Usually all fields are None except one
#[derive(Content, Debug, Default, Deserialize, Clone)]
pub struct MFile {
pub hardbreaks: bool,
pub blog: Option<FileMetadataBlog>,
pub contact: Option<FileMetadataContact>,
pub index: Option<FileMetadataIndex>,
pub portfolio: Option<FileMetadataPortfolio>,
}
#[allow(clippy::struct_excessive_bools)]
/// Global metadata
#[derive(Content, Debug, Clone)]
pub struct Metadata {
pub info: MFile,
pub math: bool,
pub mermaid: bool,
pub syntax_highlight: bool,
pub mail_obfsucated: bool,
}
impl Metadata {
/// Update current metadata boolean fields, keeping true ones
pub fn merge(&mut self, other: &Self) {
self.math = self.math || other.math;
self.mermaid = self.mermaid || other.mermaid;
self.syntax_highlight = self.syntax_highlight || other.syntax_highlight;
}
}
/// Deserialize metadata based on a type
fn deserialize_metadata<T: Default + serde::de::DeserializeOwned>(text: &str) -> T {
serde_yml::from_str(text.trim().trim_matches(&['-'] as &[_])).unwrap_or_default()
}
/// Fetch metadata from AST
pub fn get<'a>(root: &'a AstNode<'a>, mtype: MType) -> MFile {
root.children()
.map(|node| match &node.data.borrow().value {
// Extract metadata from frontmatter
NodeValue::FrontMatter(text) => match mtype {
MType::Blog => {
let metadata = deserialize_metadata::<FileMetadataBlog>(text);
MFile {
blog: Some(metadata.clone()),
hardbreaks: metadata.hardbreaks.unwrap_or_default(),
..MFile::default()
}
}
MType::Contact => {
let mut metadata = deserialize_metadata::<FileMetadataContact>(text);
// Trim descriptions
if let Some(desc) = &mut metadata.description {
desc.clone_from(&desc.trim().into());
}
MFile {
contact: Some(metadata),
..MFile::default()
}
}
MType::Generic => MFile {
hardbreaks: deserialize_metadata(text),
..MFile::default()
},
MType::Index => MFile {
index: Some(deserialize_metadata(text)),
..MFile::default()
},
MType::Portfolio => MFile {
portfolio: Some(deserialize_metadata(text)),
..MFile::default()
},
},
_ => MFile {
hardbreaks: true,
..MFile::default()
},
})
.next()
.unwrap_or_default()
}

View file

@ -1,291 +0,0 @@
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
};
use ::rss::{
extension::atom::{AtomExtension, Link},
Category, Channel, Guid, Image, Item,
};
use cached::proc_macro::once;
use chrono::{DateTime, Datelike, Local, NaiveDateTime, Utc};
use chrono_tz::Europe;
use comrak::{parse_document, Arena};
use ramhorns::Content;
use walkdir::WalkDir;
use crate::{
config::Config,
template::InfosPage,
utils::{
date::Date,
markdown::{get_options, File},
metadata::{get, FileMetadataBlog, MType},
misc::{get_url, make_kw, read_file},
},
};
pub const MIME_TYPE_RSS: &str = "application/rss+xml";
pub const BLOG_DIR: &str = "blog";
pub const POST_DIR: &str = "posts";
#[derive(Content, Debug)]
pub struct Post {
title: String,
pub date: Date,
pub url: String,
desc: Option<String>,
content: Option<String>,
tags: Vec<String>,
}
impl Post {
// Fetch the file content
fn fetch_content(&mut self, data_dir: &str) {
let blog_dir = format!("{data_dir}/{BLOG_DIR}/{POST_DIR}");
let ext = ".md";
if let Some(file) = read_file(format!("{blog_dir}/{}{ext}", self.url), MType::Blog) {
self.content = Some(file.content);
}
}
}
impl Hash for Post {
fn hash<H: Hasher>(&self, state: &mut H) {
if let Some(content) = &self.content {
content.hash(state);
}
}
}
pub fn get_posts(location: &str) -> Vec<Post> {
WalkDir::new(location)
.into_iter()
.filter_map(Result::ok)
.filter(|entry| {
entry.file_type().is_file() && entry.path().extension().is_some_and(|s| s == "md")
})
.filter_map(|f| {
let fname = f.file_name();
let filename = fname.to_string_lossy();
let file_without_ext = filename.split_at(filename.len() - 3).0;
let file_metadata = std::fs::read_to_string(f.path()).map_or_else(
|_| FileMetadataBlog {
title: Some(file_without_ext.into()),
..FileMetadataBlog::default()
},
|text| {
let arena = Arena::new();
let options = get_options();
let root = parse_document(&arena, &text, &options);
let mut metadata = get(root, MType::Blog).blog.unwrap();
// Always have a title
metadata.title = metadata
.title
.map_or_else(|| Some(file_without_ext.into()), Some);
metadata
},
);
if file_metadata.publish == Some(true) && file_metadata.draft != Some(true) {
let url =
f.path().to_string_lossy().strip_prefix(location).unwrap()[1..].to_owned();
Some(Post {
url: url[..url.len() - 3].to_owned(),
title: file_metadata.title.unwrap(),
date: file_metadata.date.unwrap_or({
let m = f.metadata().unwrap();
let date = std::convert::Into::<DateTime<Utc>>::into(
m.modified().unwrap_or_else(|_| m.created().unwrap()),
)
.date_naive();
Date {
day: date.day(),
month: date.month(),
year: date.year(),
}
}),
desc: file_metadata.description,
content: None,
tags: file_metadata
.tags
.unwrap_or_default()
.iter()
.map(|t| t.name.clone())
.collect(),
})
} else {
None
}
})
.collect::<Vec<Post>>()
}
pub fn get_post(
post: &mut Option<File>,
filename: &str,
name: &str,
data_dir: &str,
) -> (InfosPage, String) {
let blog_dir = format!("{data_dir}/{BLOG_DIR}/{POST_DIR}");
let ext = ".md";
*post = read_file(format!("{blog_dir}/{filename}{ext}"), MType::Blog);
let default = (
filename,
&format!("Blog d'{name}"),
Vec::new(),
String::new(),
);
let (title, desc, tags, toc) = match post {
Some(data) => (
match &data.metadata.info.blog.as_ref().unwrap().title {
Some(text) => text,
None => default.0,
},
match &data.metadata.info.blog.as_ref().unwrap().description {
Some(desc) => desc,
None => default.1,
},
match &data.metadata.info.blog.as_ref().unwrap().tags {
Some(tags) => tags.clone(),
None => default.2,
},
match &data.metadata.info.blog.as_ref().unwrap().toc {
// TODO: Generate TOC
Some(true) => String::new(),
_ => default.3,
},
),
None => default,
};
(
InfosPage {
title: Some(format!("Post: {title}")),
desc: Some(desc.clone()),
kw: Some(make_kw(
&["blog", "blogging", "write", "writing"]
.into_iter()
.chain(tags.iter().map(|t| t.name.as_str()))
.collect::<Vec<_>>(),
)),
},
toc,
)
}
#[once(time = 10800)] // 3h
pub fn build_rss(config: Config) -> String {
let mut posts = get_posts(&format!(
"{}/{}/{}",
config.locations.data_dir, BLOG_DIR, POST_DIR
));
// Sort from newest to oldest
posts.sort_by_cached_key(|p| (p.date.year, p.date.month, p.date.day));
posts.reverse();
// Only the 20 newest
let max = 20;
if posts.len() > max {
posts.drain(max..);
}
let link_to_site = get_url(config.fc.clone());
let author = if let (Some(mail), Some(name)) = (config.fc.mail, config.fc.fullname.clone()) {
Some(format!("{mail} ({name})"))
} else {
None
};
let title = format!("Blog d'{}", config.fc.name.unwrap_or_default());
let lang = "fr";
let channel = Channel {
title: title.clone(),
link: link_to_site.clone(),
description: "Un fil qui parle d'informatique notamment".into(),
language: Some(lang.into()),
managing_editor: author.clone(),
webmaster: author,
pub_date: Some(Local::now().to_rfc2822()),
categories: ["blog", "blogging", "write", "writing"]
.iter()
.map(|&c| Category {
name: c.into(),
..Category::default()
})
.collect(),
generator: Some("ewp with rss crate".into()),
docs: Some("https://www.rssboard.org/rss-specification".into()),
image: Some(Image {
url: format!("{link_to_site}/icons/favicon-32x32.png"),
title: title.clone(),
link: link_to_site.clone(),
..Image::default()
}),
items: posts
.iter_mut()
.map(|p| {
// Get post data
p.fetch_content(&config.locations.data_dir);
// Build item
Item {
title: Some(p.title.clone()),
link: Some(format!("{}/blog/p/{}", link_to_site, p.url)),
description: p.content.clone(),
categories: p
.tags
.iter()
.map(|c| Category {
name: c.to_owned(),
..Category::default()
})
.collect(),
guid: Some(Guid {
value: format!("urn:hash:{}", {
let mut hasher = DefaultHasher::new();
p.hash(&mut hasher);
hasher.finish()
}),
permalink: false,
}),
pub_date: Some(
NaiveDateTime::parse_from_str(
&format!("{}-{}-{} 13:12:00", p.date.day, p.date.month, p.date.year),
"%d-%m-%Y %H:%M:%S",
)
.unwrap()
.and_local_timezone(Europe::Paris)
.unwrap()
.to_rfc2822(),
),
..Item::default()
}
})
.collect(),
atom_ext: Some(AtomExtension {
links: vec![Link {
href: format!("{link_to_site}/blog/rss"),
rel: "self".into(),
hreflang: Some(lang.into()),
mime_type: Some(MIME_TYPE_RSS.into()),
title: Some(title),
length: None,
}],
}),
..Channel::default()
};
std::str::from_utf8(&channel.write_to(Vec::new()).unwrap())
.unwrap()
.into()
}

View file

@ -1,67 +0,0 @@
use cached::proc_macro::once;
use glob::glob;
use std::fs::read_to_string;
use crate::utils::{markdown::File, metadata::MType, misc::read_file};
/// Contact node
#[derive(Clone, Debug)]
pub struct Link {
pub service: String,
pub scope: Option<String>,
pub url: String,
}
#[once(time = 60)]
pub fn find_links(directory: String) -> Vec<Link> {
// TOML filename
let toml_file = "links.toml";
// Read the TOML file and parse it
let toml_str = read_to_string(format!("{directory}/{toml_file}")).unwrap_or_default();
let mut redirections = vec![];
match toml::de::from_str::<toml::Value>(&toml_str) {
Ok(data) => {
if let Some(section) = data.as_table() {
section.iter().for_each(|(key, value)| {
// Scopes are delimited with `/`
let (service, scope) = match key.split_once('/') {
Some((service, scope)) => (service.to_owned(), Some(scope.to_owned())),
None => (key.to_owned(), None),
};
redirections.push(Link {
service,
scope,
url: value.as_str().unwrap().to_owned(),
});
});
}
}
Err(_) => return vec![],
}
redirections
}
pub fn remove_paragraphs(list: &mut [File]) {
list.iter_mut()
.for_each(|file| file.content = file.content.replace("<p>", "").replace("</p>", ""));
}
pub fn read(path: &str) -> Vec<File> {
glob(path)
.unwrap()
.map(|e| read_file(e.unwrap().to_string_lossy().to_string(), MType::Contact).unwrap())
.filter(|f| {
!f.metadata
.info
.contact
.clone()
.unwrap()
.hide
.unwrap_or_default()
})
.collect::<Vec<File>>()
}

View file

@ -1,90 +0,0 @@
use std::collections::HashMap;
use ramhorns::Content;
use reqwest::Error;
use crate::utils::github::{fetch_pr, ProjectState};
#[derive(Clone, Content, Debug)]
pub struct Project {
name: String,
url: String,
pub pulls_merged: Vec<Pull>,
pub pulls_open: Vec<Pull>,
pub pulls_closed: Vec<Pull>,
}
#[derive(Clone, Content, Debug)]
pub struct Pull {
url: String,
id: u32,
name_repo: String,
title: String,
state: u8,
}
pub async fn fetch() -> Result<Vec<Project>, Error> {
match fetch_pr().await {
Ok(projects) => {
let mut data: Vec<Project> = Vec::new();
// Grouping PRs by projects
let mut map: HashMap<&str, Vec<Pull>> = HashMap::new();
for p in &projects {
let project = Pull {
url: p.contrib_url.clone(),
id: p.id,
name_repo: p.name.clone(),
title: p.title.clone(),
state: p.status as u8,
};
let project_name = p.name.as_str();
if map.contains_key(project_name) {
map.entry(project_name).and_modify(|v| v.push(project));
} else {
data.push(Project {
name: project_name.into(),
url: p.url.clone(),
pulls_merged: Vec::new(),
pulls_closed: Vec::new(),
pulls_open: Vec::new(),
});
map.insert(project_name, vec![project]);
}
}
// Distributes each PR in the right vector
for d in &mut data {
map.get(d.name.as_str()).unwrap().iter().for_each(|p| {
let state = p.state.try_into().unwrap();
match state {
ProjectState::Closed => d.pulls_closed.push(p.to_owned()),
ProjectState::Merged => d.pulls_merged.push(p.to_owned()),
ProjectState::Open => d.pulls_open.push(p.to_owned()),
}
});
let mut name: Vec<char> = d.name.replace('-', " ").chars().collect();
name[0] = name[0].to_uppercase().next().unwrap();
d.name = name.into_iter().collect();
}
// Ascending order by pulls IDs
for d in &mut data {
d.pulls_closed.reverse();
d.pulls_merged.reverse();
d.pulls_open.reverse();
}
// Ascending order by number of pulls
data.sort_by(|a, b| {
b.pulls_merged
.len()
.partial_cmp(&a.pulls_merged.len())
.unwrap()
});
Ok(data)
}
Err(e) => Err(e),
}
}

View file

@ -1,106 +0,0 @@
use std::{cmp::Ordering, path::Path};
use cached::proc_macro::once;
use regex::Regex;
use serde::Serialize;
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
pub struct FileNode {
name: String,
is_dir: bool,
children: Vec<FileNode>,
}
impl Ord for FileNode {
fn cmp(&self, other: &Self) -> Ordering {
match (self.is_dir, other.is_dir) {
// If both are directories or both are files, compare names
(true, true) | (false, false) => self.name.cmp(&other.name),
// If self is directory and other is file, self comes first
(true, false) => Ordering::Less,
// If self is file and other is directory, other comes first
(false, true) => Ordering::Greater,
}
}
}
impl PartialOrd for FileNode {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
#[once(time = 120)]
pub fn get_filetree(
initial_dir: &str,
exclusion_list: &[String],
exclusion_patterns: &[Regex],
) -> FileNode {
gen_filetree(initial_dir, exclusion_list, exclusion_patterns)
}
fn gen_filetree(
dir_path: &str,
exclusion_list: &[String],
exclusion_patterns: &[Regex],
) -> FileNode {
let mut children: Vec<FileNode> = std::fs::read_dir(dir_path)
.unwrap()
.filter_map(Result::ok)
.filter_map(|entry| {
let entry_path = entry.path();
let entry_name = entry_path.file_name()?.to_string_lossy().to_string();
// Exclusion checks
if excluded(&entry_name, exclusion_list, exclusion_patterns) {
return None;
}
if entry_path.is_file() {
Some(FileNode {
name: entry_name,
is_dir: false,
children: vec![],
})
} else {
// Exclude empty directories
let children_of_children = gen_filetree(
entry_path.to_str().unwrap(),
exclusion_list,
exclusion_patterns,
);
if children_of_children.is_dir && children_of_children.children.is_empty() {
None
} else {
Some(children_of_children)
}
}
})
.collect();
children.sort();
FileNode {
name: Path::new(dir_path)
.file_name()
.unwrap()
.to_string_lossy()
.to_string(),
is_dir: true,
children,
}
}
pub fn excluded(element: &str, exclusion_list: &[String], exclusion_patterns: &[Regex]) -> bool {
if exclusion_list
.iter()
.any(|excluded_term| element.contains(excluded_term))
{
return true;
}
if exclusion_patterns.iter().any(|re| re.is_match(element)) {
return true;
}
false
}

View file

@ -1,4 +0,0 @@
pub mod blog;
pub mod contact;
pub mod contrib;
pub mod cours;

View file

@ -1,13 +1,21 @@
@import "../markdown.css";
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
--code-font-color: #333333;
--code-bg-color: #eeeeee;
--quote-border-color: #9852fa;
--quote-bg-color: #d8d6d6;
--separator-color: #cccccc;
--tag-bg-color: #d2e0f0; --tag-bg-color: #d2e0f0;
} }
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--code-font-color: #eeeeee;
--code-bg-color: #333333;
--quote-border-color: #bd93f9;
--quote-bg-color: #273341;
--separator-color: #414558;
--tag-bg-color: #242e38; --tag-bg-color: #242e38;
} }
} }
@ -16,6 +24,11 @@
--max-width: 750px; --max-width: 750px;
} }
/* Page */
html {
scroll-behavior: smooth;
}
body { body {
max-width: var(--max-width); max-width: var(--max-width);
margin: auto; margin: auto;
@ -57,8 +70,49 @@ main {
max-width: 100%; max-width: 100%;
} }
/* Anchors */
:is(h1, h2, h3, h4, h5, h6):hover a.anchor::before {
visibility: visible;
}
a.anchor::before {
content: "#";
visibility: hidden;
padding-right: 0.1em;
}
a.anchor {
text-decoration: none;
vertical-align: baseline;
}
/* Links in headers */
:is(h1, h2, h3, h4, h5, h6) a {
font-size: inherit;
}
/* Separators */
hr {
border: 0;
height: 1px;
background: var(--separator-color);
}
/* Quotes */
blockquote {
margin: 1em 0;
padding: 0.1em 10px;
border-left: 6px solid;
border-color: var(--quote-border-color);
background: var(--quote-bg-color);
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
/* Images */ /* Images */
img { img {
display: block;
margin: auto;
max-width: var(--max-width); max-width: var(--max-width);
} }
@ -68,6 +122,115 @@ code {
font-family: monospace; font-family: monospace;
} }
/* Little snippet of code (not blocks) */
kbd,
code:not(.hljs):not(:has(svg)) {
background: var(--code-bg-color);
border-radius: 3px;
color: var(--code-font-color);
box-shadow: 0 1px 1px black;
font-size: calc(var(--font-size) * 0.8);
padding: 2px 4px;
vertical-align: 1.5px;
}
/* Code blocks */
.hljs {
border-radius: 5px;
}
.hljs::-webkit-scrollbar {
width: 7px;
height: 9px;
background: var(--background);
}
.hljs::-webkit-scrollbar-thumb {
background-color: var(--font-color);
border-radius: 10px;
}
/* Marge for numbers */
.hljs-ln-n {
margin-right: 0.4em;
}
/* Numbers in codeblocks */
.hljs-ln-numbers {
text-align: right;
color: var(--font-color);
}
/* Fix scroll in codeblocks with line numbering */
table.hljs-ln {
overflow: hidden;
}
/* Background for copy code button */
.hljs-copy-button {
background-color: var(--background) !important;
}
/* Light theme for the copy code button */
@media (prefers-color-scheme: light) {
.hljs-copy-button {
background-color: var(--font-color) !important;
filter: invert(100%);
}
}
/* Hide last line in codeblocks if empty */
.hljs-ln
> tbody
> tr:last-child:has(td:last-child > span::-moz-only-whitespace) {
visibility: collapse;
}
/* Temporary fix for layout.css.has-selector.enabled available only on
* Firefox under certain circumstances */
.hljs-ln > tbody > tr:last-child {
visibility: collapse;
}
/* Reference to footnotes */
.footnote-ref a {
text-decoration: underline dotted;
font-size: calc(var(--font-size) * 0.8);
}
/* Footnote */
section.footnotes * {
font-size: calc(var(--font-size) * 0.8);
}
/* When multiple ref */
a.footnote-backref sup {
font-size: calc(var(--font-size) * 0.6);
}
a.footnote-backref sup::before {
content: "(";
}
a.footnote-backref sup::after {
content: ")";
}
/* Footnotes links */
a.footnote-backref {
font-family: "Segoe UI", "Segoe UI Symbol", system-ui;
text-decoration: underline dotted;
}
/* Footnotes block separation from article */
section.footnotes {
margin: 3px;
border-top: 2px dotted var(--separator-color);
}
/* Mermaid diagrams */
pre:has(code.language-mermaid) {
text-align: center;
}
/* Table of content */ /* Table of content */
nav#toc { nav#toc {
position: fixed; position: fixed;
@ -83,3 +246,36 @@ nav#toc {
visibility: hidden; visibility: hidden;
} }
} }
@media print {
/* Better colors for paper */
blockquote {
border-color: black;
background: var(--background);
}
.hljs {
background: var(--background);
}
/* Force line numbering to be on top */
td.hljs-ln-line {
vertical-align: top;
}
/* Break code */
code.hljs {
white-space: break-spaces;
hyphens: none;
}
/* Hide arrows of backref */
a.footnote-backref {
visibility: hidden;
}
/* No underline for footnotes */
.footnote-ref > a {
text-decoration: none;
}
}

View file

@ -1,13 +1,3 @@
@import "markdown.css";
:root {
--max-width: 900px;
}
main {
max-width: var(--max-width);
}
/* Filetree */ /* Filetree */
aside { aside {
float: left; float: left;
@ -52,10 +42,12 @@ aside li.directory {
@media print { @media print {
aside { aside {
display: none; visibility: hidden;
} }
} }
main img { main img {
max-width: 100%; max-width: 100%;
display: block;
margin: auto;
} }

View file

@ -1,261 +0,0 @@
@media (prefers-color-scheme: light) {
:root {
--code-font-color: #333333;
--code-bg-color: #eeeeee;
--quote-border-color: #9852fa;
--quote-bg-color: #d8d6d6;
--separator-color: #cccccc;
--tag-bg-color: #d2e0f0;
}
}
@media (prefers-color-scheme: dark) {
:root {
--code-font-color: #eeeeee;
--code-bg-color: #333333;
--quote-border-color: #bd93f9;
--quote-bg-color: #273341;
--separator-color: #414558;
--tag-bg-color: #242e38;
}
}
/* Page */
html {
scroll-behavior: smooth;
}
/* Anchors */
main :is(h1, h2, h3, h4, h5, h6):hover a.anchor::before {
visibility: visible;
}
main a.anchor::before {
content: "#";
visibility: hidden;
padding-right: 0.1em;
}
main a.anchor {
text-decoration: none;
vertical-align: baseline;
}
/* Links in headers */
:is(h1, h2, h3, h4, h5, h6) a {
font-size: inherit;
}
/* Images */
main img {
display: block;
margin: auto;
}
/* Separators */
hr {
border: 0;
height: 1px;
background: var(--separator-color);
}
/* Quotes */
blockquote {
margin: 1em 0;
padding: 0.1em 10px;
border-left: 6px solid;
border-color: var(--quote-border-color);
background: var(--quote-bg-color);
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
/* Little snippet of code (not blocks) */
kbd,
code:not(.hljs):not(:has(svg)) {
background: var(--code-bg-color);
border-radius: 3px;
color: var(--code-font-color);
box-shadow: 0 1px 1px black;
font-size: calc(var(--font-size) * 0.8);
padding: 2px 4px;
vertical-align: 1.5px;
}
/* Code blocks */
.hljs {
border-radius: 5px;
}
.hljs::-webkit-scrollbar {
width: 7px;
height: 9px;
background: var(--background);
}
.hljs::-webkit-scrollbar-thumb {
background-color: var(--font-color);
border-radius: 10px;
}
/* Marge for numbers */
.hljs-ln-n {
margin-right: 0.4em;
}
/* Numbers in codeblocks */
.hljs-ln-numbers {
text-align: right;
color: var(--font-color);
}
/* Fix scroll in codeblocks with line numbering */
table.hljs-ln {
overflow: hidden;
}
/* Background for copy code button */
.hljs-copy-button {
background-color: var(--background) !important;
}
/* Light theme for the copy code button */
@media (prefers-color-scheme: light) {
.hljs-copy-button {
background-color: var(--font-color) !important;
filter: invert(100%);
}
}
/* Hide last line in codeblocks if empty */
.hljs-ln
> tbody
> tr:last-child:has(td:last-child > span::-moz-only-whitespace) {
visibility: collapse;
}
/* Temporary fix for layout.css.has-selector.enabled available only on
* Firefox under certain circumstances */
.hljs-ln > tbody > tr:last-child {
visibility: collapse;
}
/* Reference to footnotes */
.footnote-ref a {
text-decoration: underline dotted;
font-size: calc(var(--font-size) * 0.8);
}
/* Footnote */
section.footnotes * {
font-size: calc(var(--font-size) * 0.8);
}
/* When multiple ref */
a.footnote-backref sup {
font-size: calc(var(--font-size) * 0.6);
}
a.footnote-backref sup::before {
content: "(";
}
a.footnote-backref sup::after {
content: ")";
}
/* Footnotes links */
a.footnote-backref {
font-family: "Segoe UI", "Segoe UI Symbol", system-ui;
text-decoration: underline dotted;
}
/* Footnotes block separation from content */
section.footnotes {
margin: 3px;
border-top: 2px dotted var(--separator-color);
}
/* Mermaid diagrams */
pre:has(code.language-mermaid) {
text-align: center;
}
/* Tables */
table:not(.hljs-ln) {
border-collapse: collapse;
margin-inline: auto;
}
table:not(.hljs-ln) th,
table:not(.hljs-ln) td {
padding: 5px;
border: 1px solid var(--separator-color);
}
table:not(.hljs-ln)th {
border-bottom: 2px solid var(--separator-color);
}
/* No borders on the outer edges of the table */
table:not(.hljs-ln) tr:last-child td {
border-bottom: 0;
}
table:not(.hljs-ln) tr:first-child th {
border-top: 0;
}
table:not(.hljs-ln) tr td:first-child,
table:not(.hljs-ln) tr th:first-child {
border-left: 0;
}
table:not(.hljs-ln) tr td:last-child,
table:not(.hljs-ln) tr th:last-child {
border-right: 0;
}
@media print {
/* Better colors for paper */
blockquote {
border-color: black;
background: var(--background);
}
.hljs {
background: var(--background);
}
/* Force line numbering to be on top */
td.hljs-ln-line {
vertical-align: top;
}
/* Break code */
code.hljs {
white-space: break-spaces;
hyphens: none;
}
/* Hide arrows of backref */
a.footnote-backref {
visibility: hidden;
}
/* No underline for footnotes */
.footnote-ref > a {
text-decoration: none;
}
/* Prevent figures from splitting accross pages */
article *:has(img),
table:not(.hljs-ln),
table:not(.hljs-ln) > * {
page-break-inside: avoid;
}
@page {
@bottom-right {
content: counter(page) "/" counter(pages);
}
}
}

View file

@ -80,16 +80,11 @@ const deepestNodeOpened = (path, options) => {
} }
}; };
const Mode = { const svgDarkTheme = () => {
Light: 1,
Dark: 2,
};
const svgChangeTheme = (mode) => {
for (const item of document.getElementsByTagName("img")) { for (const item of document.getElementsByTagName("img")) {
if (!item.src.startsWith("data:image/svg+xml;base64,")) { if (!item.src.startsWith("data:image/svg+xml;base64,")) {
// Exclude image who aren't SVG and base64 encoded // Exclude image who aren't SVG and base64 encoded
continue; break;
} }
/** Convert to grayscale */ /** Convert to grayscale */
@ -134,19 +129,9 @@ const svgChangeTheme = (mode) => {
const totalGrayscale = grayscaleValues.reduce((acc, val) => acc + val, 0); const totalGrayscale = grayscaleValues.reduce((acc, val) => acc + val, 0);
const averageGrayscale = totalGrayscale / grayscaleValues.length; const averageGrayscale = totalGrayscale / grayscaleValues.length;
const treshold = 128; if (averageGrayscale < 128) {
if (averageGrayscale < treshold && mode === Mode.Dark) {
item.style = "filter: invert(1);"; item.style = "filter: invert(1);";
continue;
} }
if (averageGrayscale > treshold && mode === Mode.Light) {
item.style = "filter: invert(1);";
continue;
}
item.style = "";
} }
}; };
@ -175,16 +160,8 @@ window.addEventListener("load", () => {
uncollapse(last_openeded); uncollapse(last_openeded);
} }
// Fix SVG images // Fix SVG images in dark mode
svgChangeTheme( if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
window.matchMedia("(prefers-color-scheme: dark)").matches svgDarkTheme();
? Mode.Dark }
: Mode.Light
);
}); });
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (event) =>
svgChangeTheme(event.matches ? Mode.Dark : Mode.Light)
);

View file

@ -2,19 +2,18 @@ window.addEventListener("load", () => {
const macros = {}; const macros = {};
for (const item of new Map( for (const item of new Map(
Object.entries({ Object.entries({
B: "mathbb{B}",
N: "mathbb{N}", N: "mathbb{N}",
R: "mathbb{R}", R: "mathbb{R}",
Z: "mathbb{Z}", Z: "mathbb{Z}",
O: "Theta", O: "Theta",
Tau: "mathrm{T}",
u: "mu",
ra: "rightarrow", ra: "rightarrow",
la: "leftarrow", la: "leftarrow",
RA: "Rightarrow", RA: "Rightarrow",
LA: "Leftarrow", LA: "Leftarrow",
lb: "llbracket", u: "mu",
rb: "rrbracket", Tau: "mathrm{T}",
lb: "textlbrackdbl",
rb: "textrbrackdbl",
}) })
)[Symbol.iterator]()) { )[Symbol.iterator]()) {
const bs = "\\"; const bs = "\\";

View file

@ -16,7 +16,7 @@
</aside> </aside>
<main> <main>
{{^content}} {{^content}}
<p>Fichier introuvable ou invalide.</p> <p>Fichier introuvable</p>
{{/content}} {{#content}} {{/content}} {{#content}}
<article>{{&content}}</article> <article>{{&content}}</article>
</main> </main>