calepin 0.0.22

A Rust CLI for preprocessing Typst documents with executable code chunks
mod commands;
mod eval;

use anyhow::{anyhow, Result};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

use crate::typst::model::LayoutPaths;

const PAGE_META_LABEL: &str = "website-metadata";
const PAGE_SYNC_SELECTOR: &str = "<calepin-page>";

#[derive(Debug, Clone)]
pub struct PreprocessMetadata {
    pub setup_json: String,
    pub page_meta: Option<Value>,
    pub chunk_queries: Vec<String>,
}

pub fn preprocess_metadata(
    typst: &Path,
    layout: &LayoutPaths,
    input: &Path,
    results_input: &str,
) -> Result<PreprocessMetadata> {
    eval::preprocess_metadata(typst, layout, input, results_input)
}

pub fn page_anchors(typst: &Path, layout: &LayoutPaths) -> Result<HashMap<String, usize>> {
    eval::page_anchors(typst, layout)
}

fn results_input(layout: &LayoutPaths) -> String {
    crate::typst::paths::artifact_reference(&layout.root, &layout.results_path)
}

fn parse_page_anchor_entries(root: &Value) -> Result<HashMap<String, usize>> {
    let array = root
        .as_array()
        .ok_or_else(|| anyhow!("typst page sync output must be an array"))?;
    let mut pages = HashMap::new();

    for item in array {
        let Some(label) = item.get("label").and_then(Value::as_str) else {
            continue;
        };
        let Some(page) = item
            .get("page")
            .and_then(Value::as_u64)
            .and_then(|page| usize::try_from(page).ok())
        else {
            continue;
        };
        pages.entry(label.to_string()).or_insert(page);
    }

    Ok(pages)
}

fn root_relative(path: &Path, root: &Path) -> PathBuf {
    path.strip_prefix(root).unwrap_or(path).to_path_buf()
}

fn split_page_meta(items: &[Value]) -> Result<(String, Option<Value>)> {
    let page_meta_label = format!("<{PAGE_META_LABEL}>");
    let mut rest = Vec::new();
    let mut page_meta = None;
    for item in items {
        let label = item.get("label").and_then(Value::as_str);
        if label == Some(page_meta_label.as_str()) {
            if page_meta.is_none() {
                page_meta = item.get("value").cloned();
            }
        } else {
            rest.push(item.clone());
        }
    }
    Ok((serde_json::to_string(&rest)?, page_meta))
}

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

    #[test]
    fn parses_page_anchor_entries() {
        let pages = parse_page_anchor_entries(&serde_json::json!([
            {"label": "chunk-1", "page": 3}
        ]))
        .unwrap();

        assert_eq!(pages.get("chunk-1"), Some(&3));
    }

    #[test]
    fn split_page_meta_separates_setup_entries_from_page_metadata() {
        let setup = serde_json::json!([
            {"func":"metadata","value":{"echo":true},"label":"<calepin-config>"},
            {"func":"metadata","value":{"title":"T","pdf":false},"label":"<website-metadata>"}
        ]);

        let (setup_json, page_meta) = split_page_meta(setup.as_array().unwrap()).unwrap();

        let setup: Value = serde_json::from_str(&setup_json).unwrap();
        assert_eq!(setup.as_array().unwrap().len(), 1);
        assert_eq!(setup[0]["label"], "<calepin-config>");
        assert_eq!(
            page_meta,
            Some(serde_json::json!({"title": "T", "pdf": false}))
        );
    }
}