webfont-generator 0.11.0

Generate webfonts (SVG, TTF, EOT, WOFF, WOFF2) from SVG icons
Documentation
#[cfg(test)]
mod tests;

use std::collections::HashMap;
use std::sync::{Arc, Mutex};

use serde_json::{Map, Value};

use super::css::{
    SharedTemplateData, TemplateDependencies, build_css_context, make_src,
    render_css_with_hbs_context, render_css_with_src_mutate,
};
use super::files::load_html_template;
use super::html::{
    build_html_context, render_default_html_with_styles, render_html_with_hbs_context,
};
use super::shared::{render_with_field_swap, to_io_err};
use crate::result::GenerateWebfontsResult;
use crate::types::FontType;

/// Caches the last rendered CSS/HTML result for repeated calls with the same urls. Cloneable so
/// an incremental `regenerate` can carry the still-valid entries (provided-URL renders, which
/// don't depend on the font hash) forward into the rebuilt template data.
#[derive(Clone, Default)]
pub(crate) struct RenderCache {
    /// Result of generateCss() with no urls (computed once).
    css_no_urls: Option<Arc<String>>,
    /// Last generateCss(urls) result.
    css_last_urls: Option<HashMap<FontType, String>>,
    css_last_result: Option<Arc<String>>,
    /// Result of generateHtml() with no urls (computed once).
    html_no_urls: Option<Arc<String>>,
    /// Last generateHtml(urls) result.
    html_last_urls: Option<HashMap<FontType, String>>,
    html_last_result: Option<Arc<String>>,
}

#[derive(Clone)]
pub(crate) struct CarriedRenderCache {
    cache: RenderCache,
    css_dependencies: TemplateDependencies,
    html_dependencies: TemplateDependencies,
    css_template_source: Option<Arc<str>>,
    html_template_source: Option<Arc<str>>,
}

pub(crate) struct CachedTemplateData {
    pub(crate) shared: SharedTemplateData,
    pub(crate) css_context: Map<String, Value>,
    pub(crate) css_hbs_context: Mutex<handlebars::Context>,
    pub(crate) html_context: Map<String, Value>,
    pub(crate) html_hbs_context: Mutex<handlebars::Context>,
    pub(crate) html_template: super::html::HtmlTemplate,
    pub(crate) render_cache: Mutex<RenderCache>,
    #[cfg(test)]
    pub(crate) html_render_count: std::sync::atomic::AtomicUsize,
}

impl GenerateWebfontsResult {
    /// Render a fixed TypeScript source module with `icons`, `IconName`,
    /// `iconBaseSelector`, and `iconClassPrefix`. Repeated calls share the output.
    /// Uses effective CSS context settings, independent of CSS/HTML templates or output flags.
    pub fn generate_ts(&self) -> std::io::Result<Arc<String>> {
        Ok(self
            .ts
            .get_or_init(|| {
                Arc::new(super::ts::render_ts(
                    &self.options,
                    self.source_files
                        .iter()
                        .map(|source| source.glyph_name.as_str()),
                    self.options.variants.is_some(),
                    self.css_context.as_ref(),
                ))
            })
            .clone())
    }

    fn validate_render_urls(
        &self,
        urls: Option<&HashMap<FontType, String>>,
    ) -> std::io::Result<()> {
        if self.options.variants.is_some()
            && urls.is_some_and(|urls| {
                urls.contains_key(&FontType::Svg) || urls.contains_key(&FontType::Eot)
            })
        {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                "SVG and EOT URLs are unsupported for variant results.",
            ));
        }
        Ok(())
    }
    #[cfg(test)]
    pub(crate) fn has_carried_css_no_urls_for_test(&self) -> bool {
        self.carried_render
            .as_ref()
            .is_some_and(|carried| carried.cache.css_no_urls.is_some())
    }

    #[cfg(test)]
    pub(crate) fn has_carried_html_no_urls_for_test(&self) -> bool {
        self.carried_render
            .as_ref()
            .is_some_and(|carried| carried.cache.html_no_urls.is_some())
    }

    pub(crate) fn get_cached_io(&self) -> std::io::Result<&CachedTemplateData> {
        self.cached
            .get_or_init(|| {
                let shared = SharedTemplateData::from_files(&self.options, &self.source_files)
                    .map_err(|e| e.to_string())?;
                let css_context = match &self.css_context {
                    Some(ctx) => ctx.clone(),
                    None => build_css_context(&self.options, &shared),
                };
                let html_context = match &self.html_context {
                    Some(ctx) => ctx.clone(),
                    None => build_html_context(&self.options, &shared, &self.source_files, None)
                        .map_err(|e| e.to_string())?,
                };
                let html_template = load_html_template(&self.options).map_err(|e| e.to_string())?;
                let render_cache = self
                    .carried_render
                    .as_ref()
                    .map(|carried| {
                        if carried.css_template_source != shared.css_template_source {
                            // HTML may embed CSS, so changing CSS invalidates both companions.
                            RenderCache::default()
                        } else if carried.html_template_source != html_template.source {
                            RenderCache {
                                html_no_urls: None,
                                html_last_urls: None,
                                html_last_result: None,
                                ..carried.cache.clone()
                            }
                        } else {
                            carried.cache.clone()
                        }
                    })
                    .unwrap_or_default();
                let css_hbs_context =
                    handlebars::Context::wraps(&css_context).map_err(|e| e.to_string())?;
                let html_hbs_context =
                    handlebars::Context::wraps(&html_context).map_err(|e| e.to_string())?;
                Ok(CachedTemplateData {
                    #[cfg(test)]
                    html_render_count: Default::default(),
                    shared,
                    css_context,
                    css_hbs_context: Mutex::new(css_hbs_context),
                    html_context,
                    html_hbs_context: Mutex::new(html_hbs_context),
                    html_template,
                    // Seed with entries carried across a regenerate;
                    // these are renders that don't depend on what changed, so reusing them is safe.
                    render_cache: Mutex::new(render_cache),
                })
            })
            .as_ref()
            .map_err(to_io_err)
    }

    pub(crate) fn reusable_render_cache(
        &self,
        names_unchanged: bool,
        codepoints_unchanged: bool,
    ) -> Option<CarriedRenderCache> {
        self.render_cache_source().map(|carried| {
            let css_deps = carried.css_dependencies;
            let css_no_urls_unchanged =
                css_deps.can_reuse_css_no_urls(names_unchanged, codepoints_unchanged);
            let css_with_urls_unchanged =
                css_deps.can_reuse_css_with_urls(names_unchanged, codepoints_unchanged);
            let html_no_urls_unchanged = carried.html_dependencies.can_reuse_html(
                names_unchanged,
                codepoints_unchanged,
                css_no_urls_unchanged,
            );
            let html_with_urls_unchanged = carried.html_dependencies.can_reuse_html(
                names_unchanged,
                codepoints_unchanged,
                css_with_urls_unchanged,
            );

            let rc = carried.cache;
            CarriedRenderCache {
                css_dependencies: carried.css_dependencies,
                html_dependencies: carried.html_dependencies,
                css_template_source: carried.css_template_source,
                html_template_source: carried.html_template_source,
                cache: RenderCache {
                    css_no_urls: css_no_urls_unchanged
                        .then(|| rc.css_no_urls.clone())
                        .flatten(),
                    html_no_urls: html_no_urls_unchanged
                        .then(|| rc.html_no_urls.clone())
                        .flatten(),
                    css_last_urls: css_with_urls_unchanged
                        .then(|| rc.css_last_urls.clone())
                        .flatten(),
                    css_last_result: css_with_urls_unchanged
                        .then(|| rc.css_last_result.clone())
                        .flatten(),
                    html_last_urls: html_with_urls_unchanged
                        .then(|| rc.html_last_urls.clone())
                        .flatten(),
                    html_last_result: html_with_urls_unchanged
                        .then(|| rc.html_last_result.clone())
                        .flatten(),
                },
            }
        })
    }

    pub(crate) fn render_cache_source(&self) -> Option<CarriedRenderCache> {
        self.cached
            .get()
            .and_then(|cached| cached.as_ref().ok())
            .map(|cached| CarriedRenderCache {
                cache: cached.render_cache.lock().unwrap().clone(),
                css_dependencies: cached.shared.css_template_dependencies,
                html_dependencies: cached.html_template.dependencies,
                css_template_source: cached.shared.css_template_source.clone(),
                html_template_source: cached.html_template.source.clone(),
            })
            .or_else(|| self.carried_render.clone())
    }

    /// Generate a CSS string for this webfont result.
    ///
    /// Pass `urls` to override the default font URLs in the CSS output.
    pub fn generate_css_pure(
        &self,
        urls: Option<HashMap<FontType, String>>,
    ) -> std::io::Result<String> {
        self.generate_css_shared(urls)
            .map(|text| text.as_ref().clone())
    }

    pub(crate) fn generate_css_shared(
        &self,
        urls: Option<HashMap<FontType, String>>,
    ) -> std::io::Result<Arc<String>> {
        self.validate_render_urls(urls.as_ref())?;
        let cached = self.get_cached_io()?;
        let mut rc = cached.render_cache.lock().unwrap();

        match &urls {
            None => {
                if let Some(result) = &rc.css_no_urls {
                    return Ok(result.clone());
                }
                let ctx = cached.css_hbs_context.lock().unwrap();
                let result = Arc::new(render_css_with_hbs_context(
                    &cached.shared,
                    &ctx,
                    &cached.css_context,
                )?);
                rc.css_no_urls = Some(result.clone());
                Ok(result)
            }
            Some(urls) => {
                // If the template doesn't reference {{src}}, URLs don't affect output
                if !cached.shared.css_template_uses_src {
                    drop(rc);
                    return self.generate_css_shared(None);
                }
                if rc.css_last_urls.as_ref() == Some(urls)
                    && let Some(result) = &rc.css_last_result
                {
                    return Ok(result.clone());
                }
                let src = make_src(&self.options, urls);
                let mut ctx = cached.css_hbs_context.lock().unwrap();
                let result = Arc::new(render_css_with_src_mutate(
                    &cached.shared,
                    &mut ctx,
                    &cached.css_context,
                    &src,
                )?);
                rc.css_last_urls = Some(urls.clone());
                rc.css_last_result = Some(result.clone());
                Ok(result)
            }
        }
    }

    /// Generate an HTML string for this webfont result.
    ///
    /// Pass `urls` to override the default font URLs in the HTML output.
    pub fn generate_html_pure(
        &self,
        urls: Option<HashMap<FontType, String>>,
    ) -> std::io::Result<String> {
        self.generate_html_shared(urls)
            .map(|text| text.as_ref().clone())
    }

    pub(crate) fn generate_html_shared(
        &self,
        urls: Option<HashMap<FontType, String>>,
    ) -> std::io::Result<Arc<String>> {
        self.validate_render_urls(urls.as_ref())?;
        let cached = self.get_cached_io()?;
        let mut rc = cached.render_cache.lock().unwrap();

        match &urls {
            None => {
                if let Some(result) = &rc.html_no_urls {
                    return Ok(result.clone());
                }
                let ctx = cached.html_hbs_context.lock().unwrap();
                #[cfg(test)]
                cached
                    .html_render_count
                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
                let result = Arc::new(render_html_with_hbs_context(
                    cached.html_template.registry.as_ref(),
                    &ctx,
                    &cached.html_context,
                )?);
                rc.html_no_urls = Some(result.clone());
                Ok(result)
            }
            Some(urls) => {
                // If the CSS template doesn't reference {{src}}, URLs don't affect output
                if !cached.shared.css_template_uses_src {
                    drop(rc);
                    return self.generate_html_shared(None);
                }
                if rc.html_last_urls.as_ref() == Some(urls)
                    && let Some(result) = &rc.html_last_result
                {
                    return Ok(result.clone());
                }
                // Render CSS with the custom URLs (in-place src mutate, no clone)
                #[cfg(test)]
                cached
                    .html_render_count
                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
                let src = make_src(&self.options, urls);
                let styles = {
                    let mut css_ctx = cached.css_hbs_context.lock().unwrap();
                    render_css_with_src_mutate(
                        &cached.shared,
                        &mut css_ctx,
                        &cached.css_context,
                        &src,
                    )?
                };
                // Hot path: default HTML template -- inject styles directly, skip clone
                if cached.html_template.registry.is_none() {
                    let result = Arc::new(render_default_html_with_styles(
                        &cached.html_context,
                        &styles,
                    ));
                    rc.html_last_urls = Some(urls.clone());
                    rc.html_last_result = Some(result.clone());
                    return Ok(result);
                }
                // Custom HTML template: in-place styles mutate, no clone
                let mut html_ctx = cached.html_hbs_context.lock().unwrap();
                let registry = cached
                    .html_template
                    .registry
                    .as_ref()
                    .expect("HTML registry should exist for custom template");
                let result = Arc::new(render_with_field_swap(
                    &mut html_ctx,
                    "styles",
                    serde_json::Value::String(styles),
                    |ctx| registry.render_with_context("html", ctx).map_err(to_io_err),
                )?);
                rc.html_last_urls = Some(urls.clone());
                rc.html_last_result = Some(result.clone());
                Ok(result)
            }
        }
    }
}