From cc933b31fd23bb06e95bf41f848a1c99353d44ae Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Fri, 25 Nov 2022 19:01:10 -0500 Subject: Start version 3 (#573) * Start version 3 * Change version to 3.0.0 in Cargo.toml * Add metadata for series * Change types for signal boosts * Add start of LaTeX resume generation at Nix time * Add start of proper author tagging for posts in JSONFeed and ldjson * Convert templates to use Maud * Add start of dynamic resume generation from dhall * Make patrons page embed thumbnails TODO: * [ ] Remove the rest of the old templates * [ ] Bring in Xeact for the share on mastodon button * [ ] Site update post Signed-off-by: Xe * fix nix builds Signed-off-by: Xe Iaso * fix dhall build Signed-off-by: Xe Iaso * fix non-flakes build Signed-off-by: Xe Iaso * make new mastodon share button Signed-off-by: Xe Iaso * remove the rest of the ructe templates that I can remove Signed-off-by: Xe Iaso * refactor blogposts to its own file Signed-off-by: Xe Iaso * move resume to be generated by nix Signed-off-by: Xe Iaso * write article Signed-off-by: Xe Iaso * blog/site-update-v3: hero image Signed-off-by: Xe Iaso * add site update series tag to site updates Signed-off-by: Xe Iaso Signed-off-by: Xe Signed-off-by: Xe Iaso --- src/app/config.rs | 90 +++++- src/app/mod.rs | 8 +- src/frontend/build.sh | 6 + src/frontend/deno.json | 8 + src/frontend/import_map.json | 8 + src/frontend/mastodon_share_button.tsx | 55 ++++ src/frontend/xeact/jsx-runtime.js | 16 + src/frontend/xeact/xeact.js | 88 +++++ src/frontend/xeact/xeact.ts | 9 + src/handlers/blog.rs | 66 ++-- src/handlers/gallery.rs | 28 +- src/handlers/mod.rs | 77 ++--- src/handlers/talks.rs | 28 +- src/main.rs | 12 +- src/post/mod.rs | 24 +- src/post/schemaorg.rs | 14 + src/signalboost.rs | 12 +- src/tmpl/asciiart.txt | 45 +++ src/tmpl/blog.rs | 215 +++++++++++++ src/tmpl/mod.rs | 573 ++++++++++++++++++++++++++++++++- src/tmpl/nag.rs | 2 +- 21 files changed, 1229 insertions(+), 155 deletions(-) create mode 100755 src/frontend/build.sh create mode 100644 src/frontend/deno.json create mode 100644 src/frontend/import_map.json create mode 100644 src/frontend/mastodon_share_button.tsx create mode 100644 src/frontend/xeact/jsx-runtime.js create mode 100644 src/frontend/xeact/xeact.js create mode 100644 src/frontend/xeact/xeact.ts create mode 100644 src/post/schemaorg.rs create mode 100644 src/tmpl/asciiart.txt create mode 100644 src/tmpl/blog.rs (limited to 'src') diff --git a/src/app/config.rs b/src/app/config.rs index e0a4d90..6499e72 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -1,7 +1,8 @@ use crate::signalboost::Person; -use maud::{html, Markup}; +use maud::{html, Markup, Render}; use serde::{Deserialize, Serialize}; use std::{ + collections::HashMap, fmt::{self, Display}, path::PathBuf, }; @@ -9,7 +10,9 @@ use std::{ #[derive(Clone, Deserialize, Default)] pub struct Config { pub signalboost: Vec, - pub authors: Vec, + pub authors: HashMap, + #[serde(rename = "defaultAuthor")] + pub default_author: Author, pub port: u16, #[serde(rename = "clackSet")] pub clack_set: Vec, @@ -19,6 +22,35 @@ pub struct Config { pub mi_token: String, #[serde(rename = "jobHistory")] pub job_history: Vec, + #[serde(rename = "seriesDescriptions")] + pub series_descriptions: Vec, + #[serde(rename = "seriesDescMap")] + pub series_desc_map: HashMap, + #[serde(rename = "notableProjects")] + pub notable_projects: Vec, + #[serde(rename = "contactLinks")] + pub contact_links: Vec, +} + +#[derive(Clone, Deserialize, Serialize, Default)] +pub struct Link { + pub url: String, + pub title: String, + pub description: String, +} + +impl Render for Link { + fn render(&self) -> Markup { + html! { + span { + a href=(self.url) {(self.title)} + @if !self.description.is_empty() { + ": " + (self.description) + } + } + } + } } #[derive(Clone, Deserialize, Serialize)] @@ -33,17 +65,51 @@ impl Default for StockKind { } } +fn schema_context() -> String { + "http://schema.org/".to_string() +} + +fn schema_person_type() -> String { + "Person".to_string() +} + #[derive(Clone, Deserialize, Serialize, Default)] pub struct Author { + #[serde(rename = "@context", default = "schema_context")] + pub context: String, + #[serde(rename = "@type", default = "schema_person_type")] + pub schema_type: String, pub name: String, + #[serde(skip_serializing)] pub handle: String, - #[serde(rename = "picUrl")] + #[serde(rename = "image", skip_serializing_if = "Option::is_none")] pub pic_url: Option, - pub link: Option, - pub twitter: Option, - pub default: bool, - #[serde(rename = "inSystem")] + #[serde(rename = "inSystem", skip_serializing)] pub in_system: bool, + #[serde(rename = "jobTitle")] + pub job_title: String, + #[serde(rename = "sameAs")] + pub same_as: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +#[derive(Clone, Deserialize, Serialize, Default)] +pub struct SeriesDescription { + pub name: String, + pub details: String, +} + +impl Render for SeriesDescription { + fn render(&self) -> Markup { + html! { + span { + a href={"/blog/series/" (self.name)} { (self.name) } + ": " + (self.details) + } + } + } } #[derive(Clone, Deserialize, Serialize, Default)] @@ -80,8 +146,8 @@ impl Display for Salary { } } -impl Salary { - pub fn html(&self) -> Markup { +impl Render for Salary { + fn render(&self) -> Markup { if self.stock.is_none() { return html! { (maud::display(self)) }; } @@ -162,15 +228,15 @@ pub struct Company { pub defunct: bool, } -impl Job { - pub fn pay_history_row(&self) -> Markup { +impl Render for Job { + fn render(&self) -> Markup { html! { tr { td { (self.title) } td { (self.start_date) } td { (self.end_date.as_ref().unwrap_or(&"current".to_string())) } td { (if self.days_worked.is_some() { self.days_worked.as_ref().unwrap().to_string() } else { "n/a".to_string() }) } - td { (self.salary.html()) } + td { (self.salary) } td { (self.leave_reason.as_ref().unwrap_or(&"n/a".to_string())) } } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 73320e1..6753ff0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,7 +1,7 @@ use crate::{post::Post, signalboost::Person}; use chrono::prelude::*; use color_eyre::eyre::Result; -use std::{fs, path::PathBuf, sync::Arc}; +use std::{path::PathBuf, sync::Arc}; use tracing::{error, instrument}; pub mod config; @@ -48,7 +48,6 @@ pub const ICON: &'static str = "https://xeiaso.net/static/img/avatar.png"; pub struct State { pub cfg: Arc, pub signalboost: Vec, - pub resume: String, pub blog: Vec, pub gallery: Vec, pub talks: Vec, @@ -62,8 +61,6 @@ pub struct State { pub async fn init(cfg: PathBuf) -> Result { let cfg: Arc = Arc::new(serde_dhall::from_file(cfg).parse()?); let sb = cfg.signalboost.clone(); - let resume = fs::read_to_string(cfg.clone().resume_fname.clone())?; - let resume: String = xesite_markdown::render(&resume)?; let mi = mi::Client::new( cfg.clone().mi_token.clone(), crate::APPLICATION_NAME.to_string(), @@ -85,7 +82,7 @@ pub async fn init(cfg: PathBuf) -> Result { everything.sort(); everything.reverse(); - let today = Utc::today(); + let today = Utc::now().date_naive(); let everything: Vec = everything .into_iter() .filter(|p| today.num_days_from_ce() >= p.date.num_days_from_ce()) @@ -141,7 +138,6 @@ pub async fn init(cfg: PathBuf) -> Result { mi, cfg, signalboost: sb, - resume, blog, gallery, talks, diff --git a/src/frontend/build.sh b/src/frontend/build.sh new file mode 100755 index 0000000..ad31763 --- /dev/null +++ b/src/frontend/build.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e + +export RUST_LOG=info +deno bundle ./mastodon_share_button.tsx ../../static/js/mastodon_share_button.js diff --git a/src/frontend/deno.json b/src/frontend/deno.json new file mode 100644 index 0000000..b763d25 --- /dev/null +++ b/src/frontend/deno.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "xeact", + "lib": ["esnext", "dom", "dom.iterable"] + }, + "importMap": "./import_map.json", +} diff --git a/src/frontend/import_map.json b/src/frontend/import_map.json new file mode 100644 index 0000000..20cfc64 --- /dev/null +++ b/src/frontend/import_map.json @@ -0,0 +1,8 @@ +{ + "imports": { + "xeact": "./xeact/xeact.ts", + "xeact/jsx-runtime": "./xeact/jsx-runtime.js", + "/": "./", + "./": "./" + } +} diff --git a/src/frontend/mastodon_share_button.tsx b/src/frontend/mastodon_share_button.tsx new file mode 100644 index 0000000..d689abd --- /dev/null +++ b/src/frontend/mastodon_share_button.tsx @@ -0,0 +1,55 @@ +import { g, r, u, x } from "xeact"; + +r(() => { + const root = g("mastodon_share_button"); + + let defaultURL = localStorage["mastodon_instance"]; + + const title = document.querySelectorAll('meta[property="og:title"]')[0] + .getAttribute("content"); + let series = g("mastodon_share_series").innerText; + if (series != "") { + series = `#${series} `; + } + const tags = g("mastodon_share_tags"); + const articleURL = u(); + + const tootTemplate = `${title} + +${articleURL} + +${series}${tags.innerText} @cadey@pony.social`; + + const instanceBox = ( + + ); + const tootBox = ; + + const doShare = () => { + const instanceURL = instanceBox.value; + localStorage["mastodon_instance"] = instanceURL; + const text = tootBox.value; + const mastodon_url = u(instanceURL + "/share", { text }); + console.log({ text, mastodon_url }); + window.open(mastodon_url, "_blank"); + }; + + const shareButton = ; + + x(root); + + root.appendChild( +
+
+ Share on Mastodon + Instance URL (https://mastodon.example) +
+ {instanceBox} +
+ {tootBox} +
+ {shareButton} +
+
, + ); +}); diff --git a/src/frontend/xeact/jsx-runtime.js b/src/frontend/xeact/jsx-runtime.js new file mode 100644 index 0000000..58ccfaa --- /dev/null +++ b/src/frontend/xeact/jsx-runtime.js @@ -0,0 +1,16 @@ +import { h } from './xeact.ts'; + +/** + * Create a DOM element, assign the properties of `data` to it, and append all `data.children`. + * + * @type{function(string, Object=): HTMLElement} + */ +export const jsx = (tag, data) => { + let children = data.children; + delete data.children; + const result = h(tag, data, children); + result.classList.value = result.class; + return result; +}; +export const jsxs = jsx; +export const jsxDEV = jsx; diff --git a/src/frontend/xeact/xeact.js b/src/frontend/xeact/xeact.js new file mode 100644 index 0000000..7be9a1c --- /dev/null +++ b/src/frontend/xeact/xeact.js @@ -0,0 +1,88 @@ +/** + * Creates a DOM element, assigns the properties of `data` to it, and appends all `children`. + * + * @type{function(string|Function, Object=, Node|Array.=)} + */ +const h = (name, data = {}, children = []) => { + const result = typeof name == "function" ? name(data) : Object.assign(document.createElement(name), data); + if (!Array.isArray(children)) { + children = [children]; + } + result.append(...children); + return result; +}; + +/** + * Create a text node. + * + * Equivalent to `document.createTextNode(text)` + * + * @type{function(string): Text} + */ +const t = (text) => document.createTextNode(text); + +/** + * Remove all child nodes from a DOM element. + * + * @type{function(Node)} + */ +const x = (elem) => { + while (elem.lastChild) { + elem.removeChild(elem.lastChild); + } +}; + +/** + * Get all elements with the given ID. + * + * Equivalent to `document.getElementById(name)` + * + * @type{function(string): HTMLElement} + */ +const g = (name) => document.getElementById(name); + +/** + * Get all elements with the given class name. + * + * Equivalent to `document.getElementsByClassName(name)` + * + * @type{function(string): HTMLCollectionOf.} + */ +const c = (name) => document.getElementsByClassName(name); + +/** @type{function(string): HTMLCollectionOf.} */ +const n = (name) => document.getElementsByName(name); + +/** + * Get all elements matching the given HTML selector. + * + * Matches selectors with `document.querySelectorAll(selector)` + * + * @type{function(string): Array.} + */ +const s = (selector) => Array.from(document.querySelectorAll(selector)); + +/** + * Generate a relative URL from `url`, appending all key-value pairs from `params` as URL-encoded parameters. + * + * @type{function(string=, Object=): string} + */ +const u = (url = "", params = {}) => { + let result = new URL(url, window.location.href); + Object.entries(params).forEach((kv) => { + let [k, v] = kv; + result.searchParams.set(k, v); + }); + return result.toString(); +}; + +/** + * Takes a callback to run when all DOM content is loaded. + * + * Equivalent to `window.addEventListener('DOMContentLoaded', callback)` + * + * @type{function(function())} + */ +const r = (callback) => window.addEventListener('DOMContentLoaded', callback); + +export { h, t, x, g, c, n, u, s, r }; diff --git a/src/frontend/xeact/xeact.ts b/src/frontend/xeact/xeact.ts new file mode 100644 index 0000000..8974ec1 --- /dev/null +++ b/src/frontend/xeact/xeact.ts @@ -0,0 +1,9 @@ +export * from "./xeact.js"; + +declare global { + export namespace JSX { + interface IntrinsicElements { + [elemName: string]: any; + } + } +} diff --git a/src/handlers/blog.rs b/src/handlers/blog.rs index 012a7ad..feeab44 100644 --- a/src/handlers/blog.rs +++ b/src/handlers/blog.rs @@ -1,14 +1,15 @@ use super::Result; -use crate::{app::State, post::Post, templates}; +use crate::{app::State, post::Post, tmpl}; use axum::{ extract::{Extension, Path}, - response::Html, + http::StatusCode, }; use http::HeaderMap; use lazy_static::lazy_static; +use maud::Markup; use prometheus::{opts, register_int_counter_vec, IntCounterVec}; use std::sync::Arc; -use tracing::{error, instrument}; +use tracing::instrument; lazy_static! { static ref HIT_COUNTER: IntCounterVec = register_int_counter_vec!( @@ -19,40 +20,27 @@ lazy_static! { } #[instrument(skip(state))] -pub async fn index(Extension(state): Extension>) -> Result { +pub async fn index(Extension(state): Extension>) -> Result { let state = state.clone(); - let mut result: Vec = vec![]; - templates::blogindex_html(&mut result, state.blog.clone())?; - Ok(Html(result)) + let result = tmpl::post_index(&state.blog, "Blogposts", true); + Ok(result) } #[instrument(skip(state))] -pub async fn series(Extension(state): Extension>) -> Result { +pub async fn series(Extension(state): Extension>) -> Result { let state = state.clone(); - let mut series: Vec = vec![]; - let mut result: Vec = vec![]; - for post in &state.blog { - if post.front_matter.series.is_some() { - series.push(post.front_matter.series.as_ref().unwrap().clone()); - } - } - - series.sort(); - series.dedup(); - - templates::series_html(&mut result, series)?; - Ok(Html(result)) + Ok(tmpl::blog_series(&state.cfg.clone().series_descriptions)) } #[instrument(skip(state))] pub async fn series_view( Path(series): Path, Extension(state): Extension>, -) -> Result { +) -> (StatusCode, Markup) { let state = state.clone(); + let cfg = state.cfg.clone(); let mut posts: Vec = vec![]; - let mut result: Vec = vec![]; for post in &state.blog { if post.front_matter.series.is_none() { @@ -64,13 +52,25 @@ pub async fn series_view( posts.push(post.clone()); } + posts.reverse(); + + let desc = cfg.series_desc_map.get(&series); + if posts.len() == 0 { - error!("series not found"); - return Err(super::Error::SeriesNotFound(series)); + ( + StatusCode::NOT_FOUND, + tmpl::error(format!("series not found: {series}")), + ) + } else { + if let Some(desc) = desc { + (StatusCode::OK, tmpl::series_view(&series, desc, &posts)) + } else { + ( + StatusCode::INTERNAL_SERVER_ERROR, + tmpl::error(format!("series metadata in dhall not found: {series}")), + ) + } } - - templates::series_posts_html(&mut result, series, &posts).unwrap(); - Ok(Html(result)) } #[instrument(skip(state, headers))] @@ -78,7 +78,7 @@ pub async fn post_view( Path(name): Path, Extension(state): Extension>, headers: HeaderMap, -) -> Result { +) -> Result<(StatusCode, Markup)> { let mut want: Option = None; let want_link = format!("blog/{}", name); @@ -96,15 +96,13 @@ pub async fn post_view( }; match want { - None => Err(super::Error::PostNotFound(name)), + None => Ok((StatusCode::NOT_FOUND, tmpl::not_found(want_link))), Some(post) => { HIT_COUNTER .with_label_values(&[name.clone().as_str()]) .inc(); - let body = templates::Html(post.body_html.clone()); - let mut result: Vec = vec![]; - templates::blogpost_html(&mut result, post, body, referer)?; - Ok(Html(result)) + let body = maud::PreEscaped(&post.body_html); + Ok((StatusCode::OK, tmpl::blog::blog(&post, body, referer))) } } } diff --git a/src/handlers/gallery.rs b/src/handlers/gallery.rs index ae6c411..25c8dfa 100644 --- a/src/handlers/gallery.rs +++ b/src/handlers/gallery.rs @@ -1,10 +1,8 @@ -use super::{Error::*, Result}; -use crate::{app::State, post::Post, templates}; -use axum::{ - extract::{Extension, Path}, - response::Html, -}; +use crate::{app::State, post::Post, tmpl}; +use axum::extract::{Extension, Path}; +use http::StatusCode; use lazy_static::lazy_static; +use maud::Markup; use prometheus::{opts, register_int_counter_vec, IntCounterVec}; use std::sync::Arc; use tracing::instrument; @@ -18,36 +16,32 @@ lazy_static! { } #[instrument(skip(state))] -pub async fn index(Extension(state): Extension>) -> Result { +pub async fn index(Extension(state): Extension>) -> Markup { let state = state.clone(); - let mut result: Vec = vec![]; - templates::galleryindex_html(&mut result, state.gallery.clone())?; - Ok(Html(result)) + tmpl::gallery_index(&state.gallery) } #[instrument(skip(state))] pub async fn post_view( Path(name): Path, Extension(state): Extension>, -) -> Result { +) -> (StatusCode, Markup) { let mut want: Option = None; + let link = format!("gallery/{}", name); for post in &state.gallery { - if post.link == format!("gallery/{}", name) { + if post.link == link { want = Some(post.clone()); } } match want { - None => Err(PostNotFound(name)), + None => (StatusCode::NOT_FOUND, tmpl::not_found(link)), Some(post) => { HIT_COUNTER .with_label_values(&[name.clone().as_str()]) .inc(); - let body = templates::Html(post.body_html.clone()); - let mut result: Vec = vec![]; - templates::gallerypost_html(&mut result, post, body)?; - Ok(Html(result)) + (StatusCode::OK, tmpl::blog::gallery(&post)) } } } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 37fcac1..a20d654 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,4 +1,4 @@ -use crate::{app::State, templates}; +use crate::{app::State, tmpl}; use axum::{ body, extract::Extension, @@ -7,6 +7,7 @@ use axum::{ }; use chrono::{Datelike, Timelike, Utc, Weekday}; use lazy_static::lazy_static; +use maud::Markup; use prometheus::{opts, register_int_counter_vec, IntCounterVec}; use std::sync::Arc; use tracing::instrument; @@ -67,82 +68,74 @@ lazy_static! { }; } -#[instrument] -pub async fn index() -> Result { +#[instrument(skip(state))] +pub async fn index(Extension(state): Extension>) -> Result { HIT_COUNTER.with_label_values(&["index"]).inc(); - let mut result: Vec = vec![]; - templates::index_html(&mut result)?; - Ok(Html(result)) + let state = state.clone(); + let cfg = state.cfg.clone(); + + Ok(tmpl::index(&cfg.default_author, &cfg.notable_projects)) } -#[instrument] -pub async fn contact() -> Result { +#[instrument(skip(state))] +pub async fn contact(Extension(state): Extension>) -> Markup { HIT_COUNTER.with_label_values(&["contact"]).inc(); - let mut result: Vec = vec![]; - templates::contact_html(&mut result)?; - Ok(Html(result)) + let state = state.clone(); + let cfg = state.cfg.clone(); + + crate::tmpl::contact(&cfg.contact_links) } #[instrument] -pub async fn feeds() -> Result { +pub async fn feeds() -> Markup { HIT_COUNTER.with_label_values(&["feeds"]).inc(); - let mut result: Vec = vec![]; - templates::feeds_html(&mut result)?; - Ok(Html(result)) + crate::tmpl::feeds() } #[axum_macros::debug_handler] #[instrument(skip(state))] -pub async fn salary_transparency(Extension(state): Extension>) -> Result { +pub async fn salary_transparency(Extension(state): Extension>) -> Result { HIT_COUNTER .with_label_values(&["salary_transparency"]) .inc(); let state = state.clone(); - let mut result: Vec = vec![]; - templates::salary_transparency(&mut result, state.cfg.clone())?; - Ok(Html(result)) + let cfg = state.cfg.clone(); + + Ok(tmpl::salary_transparency(&cfg.job_history)) } #[axum_macros::debug_handler] -#[instrument(skip(state))] -pub async fn resume(Extension(state): Extension>) -> Result { +pub async fn resume() -> Markup { HIT_COUNTER.with_label_values(&["resume"]).inc(); - let state = state.clone(); - let mut result: Vec = vec![]; - templates::resume_html(&mut result, templates::Html(state.resume.clone()))?; - Ok(Html(result)) + + tmpl::resume() } #[instrument(skip(state))] -pub async fn patrons(Extension(state): Extension>) -> Result { +pub async fn patrons(Extension(state): Extension>) -> (StatusCode, Markup) { HIT_COUNTER.with_label_values(&["patrons"]).inc(); let state = state.clone(); - let mut result: Vec = vec![]; match &state.patrons { - None => Err(Error::NoPatrons), - Some(patrons) => { - templates::patrons_html(&mut result, patrons.clone())?; - Ok(Html(result)) - } + None => ( + StatusCode::INTERNAL_SERVER_ERROR, + tmpl::error("Patreon API config is broken, no patrons in ram"), + ), + Some(patrons) => (StatusCode::IM_A_TEAPOT, tmpl::patrons(&patrons)), } } #[axum_macros::debug_handler] #[instrument(skip(state))] -pub async fn signalboost(Extension(state): Extension>) -> Result { +pub async fn signalboost(Extension(state): Extension>) -> Markup { HIT_COUNTER.with_label_values(&["signalboost"]).inc(); let state = state.clone(); - let mut result: Vec = vec![]; - templates::signalboost_html(&mut result, state.signalboost.clone())?; - Ok(Html(result)) + tmpl::signalboost(&state.signalboost) } #[instrument] -pub async fn not_found() -> Result { +pub async fn not_found(uri: axum::http::Uri) -> (StatusCode, Markup) { HIT_COUNTER.with_label_values(&["not_found"]).inc(); - let mut result: Vec = vec![]; - templates::notfound_html(&mut result, "some path".into())?; - Ok(Html(result)) + (StatusCode::NOT_FOUND, tmpl::not_found(uri.path())) } #[derive(Debug, thiserror::Error)] @@ -170,8 +163,8 @@ pub type Result>> = std::result::Result; impl IntoResponse for Error { fn into_response(self) -> Response { - let mut result: Vec = vec![]; - templates::error_html(&mut result, format!("{}", self)).unwrap(); + let result = tmpl::error(format!("{}", self)); + let result = result.0; let body = body::boxed(body::Full::from(result)); diff --git a/src/handlers/talks.rs b/src/handlers/talks.rs index 59d8676..262e481 100644 --- a/src/handlers/talks.rs +++ b/src/handlers/talks.rs @@ -1,11 +1,9 @@ -use super::{Error::*, Result}; -use crate::{app::State, post::Post, templates}; -use axum::{ - extract::{Extension, Path}, - response::Html, -}; -use http::header::HeaderMap; +use super::Result; +use crate::{app::State, post::Post, tmpl}; +use axum::extract::{Extension, Path}; +use http::{header::HeaderMap, StatusCode}; use lazy_static::lazy_static; +use maud::Markup; use prometheus::{opts, register_int_counter_vec, IntCounterVec}; use std::sync::Arc; use tracing::instrument; @@ -19,11 +17,9 @@ lazy_static! { } #[instrument(skip(state))] -pub async fn index(Extension(state): Extension>) -> Result { +pub async fn index(Extension(state): Extension>) -> Result { let state = state.clone(); - let mut result: Vec = vec![]; - templates::talkindex_html(&mut result, state.talks.clone())?; - Ok(Html(result)) + Ok(tmpl::post_index(&state.talks, "Talks", false)) } #[instrument(skip(state, headers))] @@ -31,7 +27,7 @@ pub async fn post_view( Path(name): Path, Extension(state): Extension>, headers: HeaderMap, -) -> Result { +) -> Result<(StatusCode, Markup)> { let mut want: Option = None; let want_link = format!("talks/{}", name); @@ -49,15 +45,13 @@ pub async fn post_view( }; match want { - None => Err(PostNotFound(name).into()), + None => Ok((StatusCode::NOT_FOUND, tmpl::not_found(want_link))), Some(post) => { HIT_COUNTER .with_label_values(&[name.clone().as_str()]) .inc(); - let body = templates::Html(post.body_html.clone()); - let mut result: Vec = vec![]; - templates::talkpost_html(&mut result, post, body, referer)?; - Ok(Html(result)) + let body = maud::PreEscaped(&post.body_html); + Ok((StatusCode::OK, tmpl::blog::talk(&post, body, referer))) } } } diff --git a/src/main.rs b/src/main.rs index a35db42..737ab45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,8 +4,9 @@ extern crate tracing; use axum::{ body, extract::Extension, + handler::Handler, http::header::{self, HeaderValue, CONTENT_TYPE}, - response::{Html, Response}, + response::Response, routing::{get, get_service}, Router, }; @@ -211,6 +212,7 @@ async fn main() -> Result<()> { ) }), ) + .fallback(handlers::not_found.into_service()) .layer(middleware); #[cfg(target_os = "linux")] @@ -276,16 +278,12 @@ async fn metrics() -> Response { .unwrap() } -async fn go_vanity() -> Html> { - let mut buffer: Vec = vec![]; - templates::gitea_html( - &mut buffer, +async fn go_vanity() -> maud::Markup { + tmpl::gitea( "christine.website/jsonfeed", "https://tulpa.dev/Xe/jsonfeed", "master", ) - .unwrap(); - Html(buffer) } include!(concat!(env!("OUT_DIR"), "/templates.rs")); diff --git a/src/post/mod.rs b/src/post/mod.rs index 8ed99bf..65c859e 100644 --- a/src/post/mod.rs +++ b/src/post/mod.rs @@ -6,6 +6,7 @@ use std::{borrow::Borrow, cmp::Ordering, path::PathBuf}; use tokio::fs; pub mod frontmatter; +pub mod schemaorg; #[derive(Eq, PartialEq, Debug, Clone, Serialize, Deserialize)] pub struct Post { @@ -26,6 +27,19 @@ pub struct NewPost { pub link: String, } +impl Into for &Post { + fn into(self) -> schemaorg::Article { + schemaorg::Article { + context: "https://schema.org".to_string(), + r#type: "Article".to_string(), + headline: self.front_matter.title.clone(), + image: "https://xeiaso.net/static/img/avatar.png".to_string(), + url: format!("https://xeiaso.net/{}", self.link), + date_published: self.date.format("%Y-%m-%d").to_string(), + } + } +} + impl Into for Post { fn into(self) -> xe_jsonfeed::Item { let mut result = xe_jsonfeed::Item::builder() @@ -99,10 +113,12 @@ async fn read_post(dir: &str, fname: PathBuf, cli: &Option) -> Resul let link = format!("{}/{}", dir, fname.file_stem().unwrap().to_str().unwrap()); let body_html = xesite_markdown::render(&body) .wrap_err_with(|| format!("can't parse markdown for {:?}", fname))?; - let date: DateTime = - DateTime::::from_utc(NaiveDateTime::new(date, NaiveTime::from_hms(0, 0, 0)), Utc) - .with_timezone(&Utc) - .into(); + let date: DateTime = DateTime::::from_utc( + NaiveDateTime::new(date, NaiveTime::from_hms_opt(0, 0, 0).unwrap()), + Utc, + ) + .with_timezone(&Utc) + .into(); let mentions: Vec = match cli { Some(cli) => cli diff --git a/src/post/schemaorg.rs b/src/post/schemaorg.rs new file mode 100644 index 0000000..18c238a --- /dev/null +++ b/src/post/schemaorg.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Eq, PartialEq, Debug, Clone, Serialize, Deserialize)] +pub struct Article { + #[serde(rename = "@context")] + pub context: String, + #[serde(rename = "@type")] + pub r#type: String, + pub headline: String, + pub image: String, + pub url: String, + #[serde(rename = "datePublished")] + pub date_published: String, +} diff --git a/src/signalboost.rs b/src/signalboost.rs index 6adfc8f..d4638d9 100644 --- a/src/signalboost.rs +++ b/src/signalboost.rs @@ -1,17 +1,11 @@ +use crate::app::config::Link; use serde::Deserialize; -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Deserialize)] pub struct Person { pub name: String, pub tags: Vec, - #[serde(rename = "gitLink")] - pub git_link: Option, - pub twitter: Option, - pub linkedin: Option, - pub fediverse: Option, - #[serde(rename = "coverLetter")] - pub cover_letter: Option, - pub website: Option, + pub links: Vec, } #[cfg(test)] diff --git a/src/tmpl/asciiart.txt b/src/tmpl/asciiart.txt new file mode 100644 index 0000000..88fe61e --- /dev/null +++ b/src/tmpl/asciiart.txt @@ -0,0 +1,45 @@ + diff --git a/src/tmpl/blog.rs b/src/tmpl/blog.rs new file mode 100644 index 0000000..4f76784 --- /dev/null +++ b/src/tmpl/blog.rs @@ -0,0 +1,215 @@ +use super::{base, nag}; +use crate::post::{schemaorg::Article, Post}; +use maud::{html, Markup, PreEscaped}; + +fn post_metadata(post: &Post) -> Markup { + let art: Article = post.into(); + let json = PreEscaped(serde_json::to_string(&art).unwrap()); + + html! { + meta name="twitter:card" content="summary"; + meta name="twitter:site" content="@theprincessxena"; + meta name="twitter:title" content={(post.front_matter.title)}; + meta property="og:type" content="website"; + meta property="og:title" content={(post.front_matter.title)}; + meta property="og:site_name" content="Xe's Blog"; + meta name="description" content={(post.front_matter.title) " - Xe's Blog"}; + meta name="author" content="Xe Iaso"; + + @if let Some(redirect_to) = &post.front_matter.redirect_to { + link rel="canonical" href=(redirect_to); + meta http-equiv="refresh" content=(format!("0;URL='{redirect_to}'")); + } @else { + link rel="canonical" href={"https://xeiaso.net/" (post.link)}; + } + + script type="application/ld+json" {(json)} + } +} + +fn share_button(post: &Post) -> Markup { + html! { + div # mastodon_share_button {} + div # mastodon_share_series style="display:none" {(post.front_matter.series.as_ref().unwrap_or(&"".to_string()))} + div # mastodon_share_tags style="display:none" {@for tag in post.front_matter.tags.as_ref().unwrap_or(&Vec::new()) {"#" (tag) " "}} + script r#type="module" src="/static/js/mastodon_share_button.js" {} + } +} + +fn twitch_vod(post: &Post) -> Markup { + html! { + @if let Some(vod) = &post.front_matter.vod { + p { + "This post was written live on " + a href="https://twitch.tv/princessxen" {"Twitch"} + ". You can check out the stream recording on " + a href=(vod.twitch) {"Twitch"} + " and on " + a href=(vod.youtube) {"YouTube"} + ". If you are reading this in the first day or so of this post being published, you will need to watch it on Twitch." + } + } + } +} + +pub fn blog(post: &Post, body: PreEscaped<&String>, referer: Option) -> Markup { + base( + Some(&post.front_matter.title), + None, + html! { + (post_metadata(post)) + (nag::referer(referer)) + + article { + h1 {(post.front_matter.title)} + + (nag::prerelease(post)) + + small { + "Read time in minutes: " + (post.read_time_estimate_minutes) + } + + (body) + } + + (share_button(post)) + (twitch_vod(post)) + + p { + "This article was posted on " + (post.detri()) + ". Facts and circumstances may have changed since publication Please " + a href="/contact" {"contact me"} + " before jumping to conclusions if something seems wrong or unclear." + } + + @if let Some(series) = &post.front_matter.series { + p { + "Series: " + a href={"/blog/series/" (series)} {(series)} + } + } + + @if let Some(tags) = &post.front_matter.tags { + p { + "Tags: " + @for tag in tags { + code {(tag)} + " " + } + } + } + + @if post.mentions.is_empty() { + p { + "This post was not " + a href="https://www.w3.org/TR/webmention/" {"WebMention"} + "ed yet. You could be the first!" + } + } @else { + ul { + @for mention in &post.mentions { + li { + a href=(mention.source) {(mention.title.as_ref().unwrap_or(&mention.source))} + } + } + } + } + + p { + "The art for Mara was drawn by " + a href="https://selic.re/" {"Selicre"} + "." + } + + p { + "The art for Cadey was drawn by " + a href="https://artzorastudios.weebly.com/" {"ArtZorea Studios"} + "." + } + }, + ) +} + +pub fn gallery(post: &Post) -> Markup { + base( + Some(&post.front_matter.title), + None, + html! { + (post_metadata(post)) + h1 {(post.front_matter.title)} + + (PreEscaped(&post.body_html)) + + center { + img src=(post.front_matter.image.as_ref().unwrap()); + } + + hr; + + p { + "This artwork was posted on " + (post.detri()) + "." + } + + @if let Some(tags) = &post.front_matter.tags { + p { + "Tags: " + @for tag in tags { + code {(tag)} + " " + } + } + } + + (share_button(post)) + }, + ) +} + +pub fn talk(post: &Post, body: PreEscaped<&String>, referer: Option) -> Markup { + base( + Some(&post.front_matter.title), + None, + html! { + (post_metadata(post)) + (nag::referer(referer)) + + article { + {(post.front_matter.title)} + + (nag::prerelease(post)) + + (body) + } + + @if let Some(slides) = &post.front_matter.slides_link { + a href=(slides) {"Link to the slides"} + } + + (share_button(post)) + + p { + "This talk was posted on " + (post.detri()) + ". Facts and circumstances may have changed since publication Please " + a href="/contact" {"contact me"} + " before jumping to conclusions if something seems wrong or unclear." + } + + p { + "The art for Mara was drawn by " + a href="https://selic.re/" {"Selicre"} + "." + } + + p { + "The art for Cadey was drawn by " + a href="https://artzorastudios.weebly.com/" {"ArtZorea Studios"} + "." + } + }, + ) +} diff --git a/src/tmpl/mod.rs b/src/tmpl/mod.rs index b2ffd0d..79b8196 100644 --- a/src/tmpl/mod.rs +++ b/src/tmpl/mod.rs @@ -1,10 +1,571 @@ -use crate::app::Config; -use maud::{html, Markup}; -use std::sync::Arc; +use crate::{app::*, post::Post, signalboost::Person}; +use chrono::prelude::*; +use lazy_static::lazy_static; +use maud::{html, Markup, PreEscaped, Render, DOCTYPE}; +use patreon::Users; +pub mod blog; pub mod nag; -pub fn salary_history(cfg: Arc) -> Markup { +lazy_static! { + static ref CACHEBUSTER: String = uuid::Uuid::new_v4().to_string().replace("-", ""); +} + +pub fn base(title: Option<&str>, styles: Option<&str>, content: Markup) -> Markup { + let now = Utc::now(); + html! { + (DOCTYPE) + (PreEscaped(include_str!("./asciiart.txt"))) + html lang="en" { + head { + title { + @if let Some(title) = title { + (title) + " - Xe Iaso" + } @else { + "Xe Iaso" + } + } + meta name="viewport" content="width=device-width, initial-scale=1.0"; + link rel="stylesheet" href={"/css/hack.css?bustCache=" (*CACHEBUSTER)}; + link rel="stylesheet" href={"/css/gruvbox-dark.css?bustCache=" (*CACHEBUSTER)}; + link rel="stylesheet" href={"/css/shim.css?bustCache=" (*CACHEBUSTER)}; + @match now.month() { + 12|1|2 => { + link rel="stylesheet" href={"/css/snow.css?bustCache=" (*CACHEBUSTER)}; + } + _ => {}, + } + link rel="manifest" href="/static/manifest.json"; + link rel="alternate" title="Xe's Blog" type="application/rss+xml" href="https://xeiaso.net/blog.rss"; + link rel="alternate" title="Xe's Blog" type="application/json" href="https://xeiaso.net/blog.json"; + link rel="apple-touch-icon" sizes="57x57" href="/static/favicon/apple-icon-57x57.png"; + link rel="apple-touch-icon" sizes="60x60" href="/static/favicon/apple-icon-60x60.png"; + link rel="apple-touch-icon" sizes="72x72" href="/static/favicon/apple-icon-72x72.png"; + link rel="apple-touch-icon" sizes="76x76" href="/static/favicon/apple-icon-76x76.png"; + link rel="apple-touch-icon" sizes="114x114" href="/static/favicon/apple-icon-114x114.png"; + link rel="apple-touch-icon" sizes="120x120" href="/static/favicon/apple-icon-120x120.png"; + link rel="apple-touch-icon" sizes="144x144" href="/static/favicon/apple-icon-144x144.png"; + link rel="apple-touch-icon" sizes="152x152" href="/static/favicon/apple-icon-152x152.png"; + link rel="apple-touch-icon" sizes="180x180" href="/static/favicon/apple-icon-180x180.png"; + link rel="icon" type="image/png" sizes="192x192" href="/static/favicon/android-icon-192x192.png"; + link rel="icon" type="image/png" sizes="32x32" href="/static/favicon/favicon-32x32.png"; + link rel="icon" type="image/png" sizes="32x32" href="/static/favicon/favicon-32x32.png"; + link rel="icon" type="image/png" sizes="96x96" href="/static/favicon/favicon-96x96.png"; + link rel="icon" type="image/png" sizes="16x16" href="/static/favicon/favicon-16x16.png"; + meta name="msapplication-TileColor" content="#ffffff"; + meta name="msapplication-TileImage" content="/static/favicon/ms-icon-144x144.png"; + meta name="theme-color" content="#ffffff"; + link href="https://mi.within.website/api/webmention/accept" rel="webmention"; + @if let Some(styles) = styles { + style { + (PreEscaped(styles)) + } + } + } + body.snow.hack.gruvbox-dark { + .container { + header { + span.logo {} + nav { + a href="/" { "Xe" } + " - " + a href="/blog" { "Blog" } + " - " + a href="/contact" { "Contact" } + " - " + a href="/resume" { "Resume" } + " - " + a href="/talks" { "Talks" } + " - " + a href="/signalboost" { "Signal Boost" } + " - " + a href="/feeds" { "Feeds" } + " | " + a target="_blank" rel="noopener noreferrer" href="https://graphviz.christine.website" { "Graphviz" } + " - " + a target="_blank" rel="noopener noreferrer" href="https://when-then-zen.christine.website/" { "When Then Zen" } + } + } + + br; + br; + + .snowframe { + (content) + } + hr; + footer { + blockquote { + "Copyright 2012-2022 Xe Iaso (Christine Dodrill). Any and all opinions listed here are my own and not representative of my employers; future, past and present." + } + p { + "Like what you see? Donate on " + a href="https://www.patreon.com/cadey" { "Patreon" } + " like " + a href="/patrons" { "these awesome people" } + "!" + } + p { + "Looking for someone for your team? Take a look " + a href="/signalboost" { "here" } + "." + } + p { + "See my salary transparency data " + a href="/salary-transparency" {"here"} + "." + } + p { + "Served by " + (env!("out")) + "/bin/xesite, see " + a href="https://github.com/Xe/site" { "source code here" } + "." + } + } + script src="/static/js/installsw.js" defer {} + } + } + } + } +} + +pub fn post_index(posts: &Vec, title: &str, show_extra: bool) -> Markup { + let today = Utc::now().date_naive(); + base( + Some(title), + None, + html! { + h1 { (title) } + @if show_extra { + p { + "If you have a compatible reader, be sure to check out my " + a href="/blog.rss" { "RSS feed" } + " for automatic updates. Also check out the " + a href="/blog.json" { "JSONFeed" } + "." + } + p { + "For a breakdown by post series, see " + a href="/blog/series" { "here" } + "." + } + } + p { + ul { + @for post in posts.iter().filter(|p| today.num_days_from_ce() >= p.date.num_days_from_ce()) { + li { + (post.detri()) + " - " + a href={"/" (post.link)} { (post.front_matter.title) } + } + } + } + } + }, + ) +} + +pub fn gallery_index(posts: &Vec) -> Markup { + base( + Some("Gallery"), + None, + html! { + h1 {"Gallery"} + + p {"Here are links to a lot of the art I have done in the last few years."} + + .grid { + @for post in posts { + .card.cell."-4of12".blogpost-card { + header."card-header" { + (post.front_matter.title) + } + .card-content { + center { + p { + "Posted on " + (post.detri()) + br; + a href={"/" (post.link)} { + img src=(post.front_matter.thumb.as_ref().unwrap()); + } + } + } + } + } + } + } + }, + ) +} + +pub fn contact(links: &Vec) -> Markup { + base( + Some("Contact Information"), + None, + html! { + h1 {"Contact Information"} + + .grid { + .cell."-6of12" { + h3 {"Email"} + p {"me@xeiaso.net"} + + h3 {"Social Media"} + ul { + @for link in links { + li {(link)} + } + } + } + .cell."-6of12" { + h3 {"Other Information"} + h4 {"Discord"} + p { + code {"Cadey~#1337"} + " Please note that Discord will automatically reject friend requests if you are not in a mutual server with me. I don't have control over this behavior." + } + } + } + }, + ) +} + +pub fn patrons(patrons: &Users) -> Markup { + base( + Some("Patrons"), + None, + html! { + h1 {"Patrons"} + + p { + "These awesome people donate to me on " + a href="https://patreon.com/cadey" {"Patreon"} + ". If you would like to show up in this list, please donate to me on Patreon. This is refreshed every time the site is deployed." + } + + .grid { + @for patron in patrons { + .cell."-3of12" { + center { + p {(patron.attributes.full_name)} + img src=(patron.attributes.thumb_url) loading="lazy"; + } + } + } + } + }, + ) +} + +pub fn signalboost(people: &Vec) -> Markup { + base( + Some("Signal Boosts"), + None, + html! { + h1 {"Signal Boosts"} + + p {"These awesome people are currently looking for a job. If you are looking for anyone with these skills, please feel free to reach out to them."} + + p { + "To add yourself to this list, fork " + a href="https://github.com/Xe/site" {"this website's source code"} + " and send a pull request with edits to " + code {"/dhall/signalboost.dhall"} + "." + } + + p {"With COVID-19 raging across the world, these people are in need of a job now more than ever."} + + h2 {"People"} + + .grid.signalboost { + @for person in people { + .cell."-4of12".content { + big {(person.name)} + + p { + @for tag in &person.tags {(tag) " "} + } + + p { + @for link in &person.links {(link) " "} + } + } + } + } + }, + ) +} + +pub fn error(why: impl Render) -> Markup { + base( + Some("Error"), + None, + html! { + h1 {"Error"} + + pre { + (why) + } + + p { + "You could try to " + a href="/" {"go home"} + " or " + a href="https://github.com/Xe/site/issues/new" {"report this issue"} + " so it can be fixed." + } + }, + ) +} + +pub fn not_found(path: impl Render) -> Markup { + base( + Some("Not found"), + None, + html! { + h1 {"Not found"} + p { + "The path at " + code {(path)} + " could not be found. If you expected this path to exist, please " + a href="https://github.com/Xe/site/issues/new" {"report this issue"} + " so it can be fixed." + } + }, + ) +} + +pub fn gitea(pkg_name: &str, git_repo: &str, branch: &str) -> Markup { + html! { + (DOCTYPE) + html { + head { + meta http-equiv="Content-Type" content="text/html; charset=utf-8"; + meta name="go-import" content={(pkg_name)" git " (git_repo)}; + meta name="go-source" content={(format!("{pkg_name} {git_repo} {git_repo}/src/{branch}{{/dir}} {git_repo}/src/{branch}{{/dir}}/{{file}}#L{{line}}"))}; + meta http-equiv="refresh" content={(format!("0; url=https://pkg.go.dev/{pkg_name}"))}; + } + body { + p { + "Please see" + a href={"https://godoc.org/" (pkg_name)} {"here"} + " for documentation on this package." + } + } + } + } +} + +pub fn resume() -> Markup { + base( + Some("Resume"), + None, + html! { + h1 {"Resume"} + + p {"This resume is automatically generated when the website gets deployed."} + + iframe src="/static/resume/resume.pdf" width="100%" height="900px" {} + + hr; + + a href="/static/resume/resume.pdf" { "PDF version" } + }, + ) +} + +fn schema_person(a: &Author) -> Markup { + let data = PreEscaped(serde_json::to_string(&a).unwrap()); + + html! { + script type="application/ld+json" { (data) } + } +} + +pub fn index(xe: &Author, projects: &Vec) -> Markup { + base( + None, + None, + html! { + link rel="authorization_endpoint" href="https://idp.christine.website/auth"; + link rel="canonical" href="https://xeiaso.net/"; + meta name="google-site-verification" content="rzs9eBEquMYr9Phrg0Xm0mIwFjDBcbdgJ3jF6Disy-k"; + (schema_person(&xe)) + + meta name="twitter:card" content="summary"; + meta name="twitter:site" content="@theprincessxena"; + meta name="twitter:title" content=(xe.name); + meta name="twitter:description" content=(xe.job_title); + meta property="og:type" content="website"; + meta property="og:title" content=(xe.name); + meta property="og:site_name" content=(xe.job_title); + meta name="description" content=(xe.job_title); + meta name="author" content=(xe.name); + + .grid { + .cell."-3of12".content { + img src="/static/img/avatar.png" alt="My Avatar"; + br; + a href="/contact" class="justify-content-center" { "Contact me" } + } + .cell."-9of12".content { + h1 {(xe.name)} + h4 {(xe.job_title)} + h5 { "Skills" } + ul { + li { "Go, Lua, Haskell, C, Rust and other languages" } + li { "Docker (deployment, development & more)" } + li { "Mashups of data" } + li { "kastermakfa" } + } + + h5 { "Highlighted Projects" } + ul { + @for project in projects { + li {(project)} + } + } + + h5 { "Quick Links" } + ul { + li {a href="https://github.com/Xe" rel="me" {"GitHub"}} + li {a href="https://twitter.com/theprincessxena" rel="me" {"Twitter"}} + li {a href="https://pony.social/@cadey" rel="me" {"Fediverse"}} + li {a href="https://www.patreon.com/cadey" rel="me" {"Patreon"}} + } + + p { + "Looking for someone for your team? Check " + a href="/signalboost" { "here" } + "." + } + } + } + }, + ) +} + +pub fn blog_series(series: &Vec) -> Markup { + base( + Some("Blogposts by series"), + None, + html! { + h1 { "Blogposts by series" } + p { + "Some posts of mine are intended to be read in order. This is a list of all the series I have written along with a little description of what it's about." + } + p { + ul { + @for set in series { + li {(set)} + } + } + } + }, + ) +} + +pub fn series_view(name: &str, desc: &str, posts: &Vec) -> Markup { + base( + Some(&format!("{name} posts")), + None, + html! { + h1 {"Series: " (name)} + + p {(desc)} + + ul { + @for post in posts { + li { + (post.detri()) + " - " + a href={"/" (post.link)} {(post.front_matter.title)} + } + } + } + }, + ) +} + +pub fn feeds() -> Markup { + base( + Some("My Feeds"), + None, + html! { + h1 { "My Feeds" } + + ul { + li { + "Blog: " + a href="/blog.atom" { "Atom" } + " - " + a href="/blog.rss" { "RSS" } + " - " + a href="/blog.json" { "JSONFeed" } + } + li { + "Mastodon: " + a href="https://pony.social/users/cadey.rss" { "RSS" } + } + } + }, + ) +} + +pub fn salary_transparency(jobs: &Vec) -> Markup { + base( + Some("Salary Transparency"), + None, + html! { + h1 {"Salary Transparency"} + + p { + "This page lists my salary for every job I've had in tech. I have had this data open to the public " + a href="https://xeiaso.net/blog/my-career-in-dates-titles-salaries-2019-03-14" {"for years"} + ", but I feel this should be more prominently displayed on my website. Other people have copied my approach of having a list of every salary they have ever been paid on their websites, and I would like to set the example by making it prominent on my website." + } + p { + "As someone who has seen pay discrimination work in action first-hand, data is one of the ways that we can end this pointless hiding of information that leads to people being uninformed and hurt by their lack of knowledge. By laying my hand out in the open like this, I hope to ensure that people are better informed about how much money they " + em {"can"} + " make, so that they can be paid equally for equal work." + } + + p { + "Please keep in mind that this table doesn't tell the complete story. If you feel like judging me about any entry in this table, please do not do it around me." + } + + h2 {"Salary Data"} + + p { + "To get this data, I have scoured over past emails, contracts and everything so that I can be sure that this information is as accurate as possible. The data on this page intentionally omits employer names. Some information may also be omitted if relevant non-disclosure agreements or similar prohibit it." + } + + (salary_history(jobs)) + + p { + "I typically update this page once any of the following things happens:" + } + + ul { + li {"I quit a job."} + li {"I get a raise/title change at the same company."} + li {"I get terminated from a job."} + li {"I get converted from a contracter to a full-time employee."} + li {"Other unspecified extranormal events happen."} + } + + p { + "Please consider publishing your salary data like this as well. By open, voluntary transparency we can help to end stigmas around discussing pay and help ensure that the next generations of people in tech are treated fairly. Stigmas thrive in darkness but die in the light of day. You can help end the stigma by playing your cards out in the open like this." + } + }, + ) +} + +fn salary_history(jobs: &Vec) -> Markup { html! { table.salary_history { tr { @@ -15,8 +576,8 @@ pub fn salary_history(cfg: Arc) -> Markup { th { "Salary" } th { "How I Left" } } - @for job in &cfg.clone().j