6.9 KiB · text History 487bb12
//! The stateless side: the editor page, its live preview, and one-off
//! exports. None of it touches the database, so it works signed out.
use axum::{
    extract::{Form, Query, State},
    http::{header, HeaderValue, StatusCode},
    response::{Html, IntoResponse, Response},
};
use minijinja::context;
use serde::Deserialize;
use tracing::error;

use super::render;
use crate::middleware::UserContext;
use crate::render::{self as card, chrome, highlight, palette, CardSpec, Output};
use crate::AppState;

/// The editor's fields. Checkboxes arrive only when ticked, and padding is
/// taken as text so an emptied number box isn't a 422.
#[derive(Debug, Deserialize)]
pub struct CardForm {
    #[serde(default)]
    pub code: String,
    #[serde(default)]
    pub title: String,
    #[serde(default)]
    pub language: String,
    #[serde(default)]
    pub chrome: String,
    #[serde(default)]
    pub palette: String,
    #[serde(default)]
    pub highlight: String,
    pub padding: Option<String>,
    pub transparent: Option<String>,
    pub line_numbers: Option<String>,
}

impl CardForm {
    pub fn into_spec(self) -> CardSpec {
        let d = CardSpec::default();
        let or = |v: String, dflt: String| if v.trim().is_empty() { dflt } else { v };
        CardSpec {
            code: self.code,
            title: self.title.trim().to_string(),
            language: or(self.language, highlight::AUTO_LANG.into()),
            chrome: or(self.chrome, d.chrome),
            palette: or(self.palette, d.palette),
            highlight: or(self.highlight, d.highlight),
            padding: self
                .padding
                .and_then(|p| p.trim().parse().ok())
                .unwrap_or(card::DEFAULT_PADDING),
            transparent: self.transparent.is_some(),
            line_numbers: self.line_numbers.is_some(),
        }
        .sanitize()
    }
}

/// Everything the editor's pickers list.
pub fn options() -> minijinja::Value {
    let (kde, neiam): (Vec<_>, Vec<_>) =
        palette::PALETTES.iter().partition(|p| p.family == palette::Family::Kde);
    context! {
        chromes => chrome::CHROMES,
        palettes_kde => kde,
        palettes_neiam => neiam,
        hl_themes => highlight::HL_THEMES,
        languages => highlight::languages(),
    }
}

/// Render the editor, either blank (the landing page) or for a saved snippet.
pub(crate) fn editor_page(
    state: &AppState,
    user: Option<UserContext>,
    spec: &CardSpec,
    slug: Option<&str>,
) -> Result<Html<String>, StatusCode> {
    render(
        state,
        "editor.html",
        context! {
            title => if slug.is_some() { "Edit snippet" } else { "New card" },
            user => user.map(|u| u.user),
            spec => spec,
            slug => slug,
            options => options(),
            max_padding => card::MAX_PADDING,
            card_svg => card::render_svg(spec, Output::Inline),
        },
    )
}

pub async fn editor(
    State(state): State<AppState>,
    user: Option<UserContext>,
) -> Result<Html<String>, StatusCode> {
    editor_page(&state, user, &CardSpec::default(), None)
}

/// htmx target for the live preview: just the inline SVG, wrapped in the id
/// the editor's `hx-select` picks out.
pub async fn preview(Form(form): Form<CardForm>) -> Html<String> {
    let spec = form.into_spec();
    Html(format!(
        r#"<div id="preview-inner">{}</div>"#,
        card::render_svg(&spec, Output::Inline)
    ))
}

#[derive(Debug, Deserialize)]
pub struct ExportQuery {
    #[serde(default)]
    pub format: String,
    pub scale: Option<f32>,
}

pub const DEFAULT_PNG_SCALE: f32 = 2.0;

/// Turn a spec into a download (`png` by default, or `svg`).
pub(crate) async fn export_response(
    spec: CardSpec,
    format: &str,
    scale: f32,
    filename: &str,
    attachment: bool,
) -> Response {
    let disposition = |ext: &str| {
        let kind = if attachment { "attachment" } else { "inline" };
        HeaderValue::from_str(&format!(r#"{kind}; filename="{filename}.{ext}""#))
            .unwrap_or_else(|_| HeaderValue::from_static("inline"))
    };
    if format == "svg" {
        let body = card::render_svg(&spec, Output::Standalone);
        return (
            [
                (header::CONTENT_TYPE, HeaderValue::from_static("image/svg+xml; charset=utf-8")),
                (header::CONTENT_DISPOSITION, disposition("svg")),
            ],
            body,
        )
            .into_response();
    }
    // Rasterising is CPU-bound; keep it off the async workers.
    let png = tokio::task::spawn_blocking(move || {
        let svg = card::render_svg(&spec, Output::Inline);
        card::render_png(&svg, scale)
    })
    .await;
    match png {
        Ok(Ok(bytes)) => (
            [
                (header::CONTENT_TYPE, HeaderValue::from_static("image/png")),
                (header::CONTENT_DISPOSITION, disposition("png")),
            ],
            bytes,
        )
            .into_response(),
        Ok(Err(e)) => {
            error!(error = %e, "png render failed");
            (StatusCode::UNPROCESSABLE_ENTITY, format!("could not render: {e}")).into_response()
        }
        Err(e) => {
            error!(error = %e, "png render task panicked");
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
        }
    }
}

/// A filesystem-safe download name from the card title.
pub(crate) fn file_stem(title: &str) -> String {
    let stem: String = title
        .chars()
        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '-' })
        .collect();
    let stem = stem.trim_matches(['-', '.']).to_string();
    if stem.is_empty() { "bismuth".into() } else { stem.chars().take(64).collect() }
}

pub async fn export(Query(q): Query<ExportQuery>, Form(form): Form<CardForm>) -> Response {
    let spec = form.into_spec();
    let name = file_stem(&spec.title);
    export_response(spec, &q.format, q.scale.unwrap_or(DEFAULT_PNG_SCALE), &name, true).await
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn form_defaults_and_checkboxes() {
        let form = CardForm {
            code: "x".into(),
            title: "  t  ".into(),
            language: "".into(),
            chrome: "".into(),
            palette: "her".into(),
            highlight: "".into(),
            padding: Some("".into()),
            transparent: Some("on".into()),
            line_numbers: None,
        };
        let spec = form.into_spec();
        assert_eq!(spec.title, "t");
        assert_eq!(spec.language, highlight::AUTO_LANG);
        assert_eq!(spec.chrome, chrome::DEFAULT_CHROME);
        assert_eq!(spec.palette, "her");
        assert_eq!(spec.padding, card::DEFAULT_PADDING);
        assert!(spec.transparent);
        assert!(!spec.line_numbers);
    }

    #[test]
    fn file_stems_are_safe() {
        assert_eq!(file_stem("main.rs — Konsole"), "main.rs---Konsole");
        assert_eq!(file_stem("../../etc/passwd"), "etc-passwd");
        assert_eq!(file_stem("\"';"), "bismuth");
    }
}