poet 0.7.0

Static site generator with optional MCP server and LLM SEO optimizations
Documentation
pub mod build_prompt_document_controller_collection_params;

use std::path::PathBuf;
use std::sync::Arc;

use anyhow::Result;
use anyhow::anyhow;
use dashmap::DashMap;
use log::info;
use rayon::iter::IntoParallelIterator as _;
use rayon::iter::ParallelIterator as _;

use crate::build_prompt_document_controller::build_prompt_document_controller;
use crate::build_prompt_document_controller_collection::build_prompt_document_controller_collection_params::BuildPromptControllerCollectionParams;
use crate::build_prompt_document_controller_params::BuildPromptDocumentControllerParams;
use crate::build_timer::BuildTimer;
use crate::document_error_collection::DocumentErrorCollection;
use crate::filesystem::Filesystem as _;
use crate::mcp::prompt_controller::PromptController;
use crate::mcp::prompt_controller_collection::PromptControllerCollection;

pub async fn build_prompt_document_controller_collection(
    BuildPromptControllerCollectionParams {
        asset_path_renderer,
        content_document_linker,
        esbuild_metafile,
        rhai_template_renderer,
        source_filesystem,
    }: BuildPromptControllerCollectionParams,
) -> Result<PromptControllerCollection> {
    info!("Processing prompt files...");

    let _build_timer = BuildTimer::default();
    let error_collection: DocumentErrorCollection = Default::default();
    let prompt_controller_map: DashMap<String, Arc<dyn PromptController>> = Default::default();

    source_filesystem
        .read_project_files()
        .await?
        .into_par_iter()
        .filter(|file| file.kind.is_prompt())
        .for_each(|file| {
            let name = file
                .get_stem_path_relative_to(&PathBuf::from("prompts"))
                .display()
                .to_string();

            match build_prompt_document_controller(BuildPromptDocumentControllerParams {
                asset_path_renderer: asset_path_renderer.clone(),
                content_document_linker: content_document_linker.clone(),
                esbuild_metafile: esbuild_metafile.clone(),
                file,
                name: name.clone(),
                rhai_template_renderer: rhai_template_renderer.clone(),
            }) {
                Ok(prompt_document_controller) => {
                    prompt_controller_map.insert(name, Arc::new(prompt_document_controller));
                }
                Err(err) => {
                    error_collection.register_error(name, err);
                }
            }
        });

    if !error_collection.is_empty() {
        return Err(anyhow!("{error_collection}"));
    }

    Ok(prompt_controller_map.into())
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use tempfile::tempdir;

    use super::*;
    use crate::asset_path_renderer::AssetPathRenderer;
    use crate::compile_shortcodes::compile_shortcodes;
    use crate::filesystem::storage::Storage;
    use crate::mcp::list_resources_cursor::ListResourcesCursor;

    async fn build(prompt_files: &[(&str, &str)]) -> Result<PromptControllerCollection> {
        let directory = tempdir()?;
        let source_filesystem = Arc::new(Storage {
            base_directory: directory.path().to_path_buf(),
        });

        for (relative_path, contents) in prompt_files {
            source_filesystem
                .set_file_contents(Path::new(relative_path), contents)
                .await?;
        }

        let rhai_template_renderer = compile_shortcodes(source_filesystem.clone()).await?;

        build_prompt_document_controller_collection(BuildPromptControllerCollectionParams {
            asset_path_renderer: AssetPathRenderer {
                base_path: "/".to_string(),
            },
            content_document_linker: Default::default(),
            esbuild_metafile: Default::default(),
            rhai_template_renderer,
            source_filesystem,
        })
        .await
    }

    #[tokio::test]
    async fn builds_a_controller_for_each_prompt_file() -> Result<()> {
        let collection = build(&[(
            "prompts/greet.md",
            "+++\narguments = {}\ndescription = \"Greeting\"\ntitle = \"Greet\"\n+++\n\n**user**: hello\n",
        )])
        .await?;

        let prompts = collection.list_mcp_prompts(ListResourcesCursor {
            offset: 0,
            per_page: 10,
        });

        assert_eq!(prompts.len(), 1);
        assert_eq!(prompts[0].name, "greet");

        Ok(())
    }

    #[tokio::test]
    async fn aggregates_errors_from_invalid_prompt_front_matter() {
        let outcome = build(&[(
            "prompts/broken.md",
            "+++\ntitle = \"Missing required fields\"\n+++\n\n**user**: hi\n",
        )])
        .await;

        assert!(outcome.is_err());
    }
}