Skip to main content

Frontmatter Processor

Implement FrontmatterProcessor::process_frontmatter() to read raw YAML fields after ingestion and index them into your own extension tables via side effects.

FrontmatterProcessor reacts to the raw YAML frontmatter of each content file during ingestion. Processors run after the content row is created or updated, so they receive the content id and slug alongside the parsed frontmatter and a database pool handle. They read the frontmatter and perform side effects (for example, writing rows to an extension table); they do not mutate the frontmatter itself.

When It Runs

Markdown file read
     |
YAML frontmatter parsed
     |
Content row inserted or updated
     |
=========================================
FrontmatterProcessor::process_frontmatter()  <- You are here
=========================================

A processor error is logged as a warning and does not fail ingestion.

The Trait

#[async_trait]
pub trait FrontmatterProcessor: Send + Sync {
    fn processor_id(&self) -> &'static str;

    fn applies_to_sources(&self) -> Vec<String> {
        vec![]  // Empty = all sources
    }

    async fn process_frontmatter(&self, ctx: &FrontmatterContext<'_>) -> ProviderResult<()>;

    fn priority(&self) -> u32 {
        100
    }
}

FrontmatterContext

The context's fields are private; use its accessors:

impl<'a> FrontmatterContext<'a> {
    pub fn content_id(&self) -> &str;
    pub fn slug(&self) -> &str;
    pub fn source_name(&self) -> &str;
    pub fn raw_frontmatter(&self) -> &serde_yaml::Value;
    pub fn db_pool<T: 'static>(&self) -> Option<&T>;
}

raw_frontmatter() is the full parsed YAML document, so custom fields that are not part of the core content schema are available here. db_pool::<T>() downcasts the pool handle to a concrete type (use systemprompt::database::DbPool).

Basic Implementation

use systemprompt::extension::prelude::{FrontmatterContext, FrontmatterProcessor};
use systemprompt::traits::{ProviderError, ProviderResult};
use async_trait::async_trait;

pub struct CustomFieldProcessor;

#[async_trait]
impl FrontmatterProcessor for CustomFieldProcessor {
    fn processor_id(&self) -> &'static str {
        "custom-fields"
    }

    async fn process_frontmatter(&self, ctx: &FrontmatterContext<'_>) -> ProviderResult<()> {
        // Read a custom frontmatter field
        let difficulty = ctx
            .raw_frontmatter()
            .get("difficulty")
            .and_then(|v| v.as_str())
            .unwrap_or("beginner")
            .to_owned();

        // Persist it against the content row via an extension table
        let pool = ctx
            .db_pool::<systemprompt::database::DbPool>()
            .ok_or_else(|| ProviderError::Internal("missing db pool".to_owned()))?;
        my_repo::upsert_difficulty(pool, ctx.content_id(), &difficulty).await?;

        Ok(())
    }
}

Registration

impl Extension for WebExtension {
    fn frontmatter_processors(&self) -> Vec<Arc<dyn FrontmatterProcessor>> {
        vec![
            Arc::new(CustomFieldProcessor),
            Arc::new(TagIndexer),
        ]
    }
}

Common Patterns

Source Filtering

Return source names from applies_to_sources() to run only for specific content sources; an empty list (the default) matches every source:

fn applies_to_sources(&self) -> Vec<String> {
    vec!["blog".to_owned()]
}

Custom Field Indexing

Read fields the core schema ignores and index them in your own table:

async fn process_frontmatter(&self, ctx: &FrontmatterContext<'_>) -> ProviderResult<()> {
    if let Some(tags) = ctx.raw_frontmatter().get("tags").and_then(|v| v.as_sequence()) {
        let normalized: Vec<String> = tags
            .iter()
            .filter_map(|t| t.as_str())
            .map(|s| s.to_lowercase().replace(' ', "-"))
            .collect();

        let pool = ctx
            .db_pool::<systemprompt::database::DbPool>()
            .ok_or_else(|| ProviderError::Internal("missing db pool".to_owned()))?;
        my_repo::replace_tags(pool, ctx.content_id(), &normalized).await?;
    }
    Ok(())
}

Ordering

When several processors apply to the same source, lower priority() values run first. The default is 100; override it when one processor depends on another's side effects.