vite-static-html 1.2.0

HTML generation support for `vite-static`
Documentation
#![warn(clippy::pedantic)]

use std::borrow::Cow;

use vite_static_shared::DynManifest;

// These types are used in documentation.
#[allow(unused_imports)]
use vite_static_shared::{Manifest, ManifestChunk};

// TODO: add importmap.json support

/// HTML integration.
///
/// Builds links, preloads for specified chunks.
///
/// ```rust
/// # use vite_static_shared::__tests::*;
/// # use vite_static_html::*;
/// #
/// // Create `HtmlIntegration` struct:
/// let html = HtmlIntegration::new(MyViteStatic.boxed())
///     // add imports of chunks: (<script> tags)
///     .import("src/main.tsx")
///     // add preloads of assets (fonts, images, etc).
///     // arguments: (preload's as="..." attribute, chunk input filename),
///     .preload("image", "src/images/500GB_image.png")
///     // add stylesheets
///     .stylesheet("src/style.scss")
///     // and then, you can build `String` of HTML (tags, joined by '\n')
///     .build();
///     // or you can build array of tags/lines
///     // .build_lines();
///
/// // The result will contain `<script>` tags, `<link rel="stylesheet" ...>` for CSS,
/// // `<link rel="preload" ...>` for image and <link rel="modulepreload" ...> for dependencies.
/// ```
pub struct HtmlIntegration<'m> {
    manifest: DynManifest<'m>,

    stylesheets: Vec<Cow<'m, str>>,
    scripts: Vec<Cow<'m, str>>,
    modulepreloads: Vec<Cow<'m, str>>,
    preloads: Vec<(&'static str, Cow<'m, ManifestChunk<'m>>)>,
}

impl<'m> HtmlIntegration<'m> {
    /// Create new [`HtmlIntegration`] builder.
    ///
    /// Takes [`DynManifest`] (boxed [`Manifest`]) and returns [`HtmlIntegration`].
    #[must_use]
    pub fn new(manifest: DynManifest<'m>) -> Self {
        Self {
            manifest,
            stylesheets: Vec::new(),
            scripts: Vec::new(),
            modulepreloads: Vec::new(),
            preloads: Vec::new(),
        }
    }

    /// Import ESM [`ManifestChunk`] by key (input filename).
    ///
    /// This function:
    ///  - adds `<script type="module" ...>` for specified chunk;
    ///  - adds module preloads for all script dependencies.
    ///
    /// ```ignore
    /// .import("src/main.tsx")
    /// .import("src/components/MyCoolComponent.tsx")
    /// .import("src/or/some/styles.scss")
    /// ```
    ///
    /// # Panics
    ///
    /// This function will panic if:
    ///
    ///   - Failed to find chunk by specified key
    ///   - Failed to find dependency (AKA imported chunk)
    #[must_use]
    pub fn import(mut self, chunk: &str) -> Self {
        self.import_as(chunk, false);
        self
    }

    // TODO: add script() method to _just_ add script tag (e.g. UMD or whatever)

    /// Preload [`ManifestChunk`] as type by key (input filename).
    ///
    /// ```ignore
    /// .preload("font", "src/assets/CoolFancyFont.ttf")
    /// .preload("image", "src/assets/500GB_image.png")
    /// .preload("script", "src/importantScript.js")
    /// ```
    ///
    /// # Panics
    ///
    /// This function will panic if specified chunk was not found.
    #[must_use]
    pub fn preload(mut self, as_filetype: &'static str, chunk: &str) -> Self {
        let chunk = self
            .manifest
            .chunk_by_key(chunk)
            .unwrap_or_else(|| panic!(r#"failed to find chunk "{chunk}""#));

        self.preloads.push((as_filetype, chunk));

        self
    }

    /// Import stylesheet by key (input filename).
    ///
    /// ```ignore
    /// .stylesheet("src/style.css")
    /// .stylesheet("src/style.scss")
    /// .stylesheet("src/style.less")
    /// .stylesheet("src/style.whatever")
    /// ```
    ///
    /// # Panics
    ///
    /// This function will panic if specified chunk was not found.
    #[must_use]
    pub fn stylesheet(mut self, chunk: &str) -> Self {
        let chunk = self
            .manifest
            .chunk_by_key(chunk)
            .unwrap_or_else(|| panic!(r#"failed to find chunk "{chunk}""#));
        self.stylesheets.push(chunk.file.clone());
        self
    }

    /// Builds HTML and returns array of lines.
    ///
    /// ```rust
    /// # use vite_static_shared::__tests::*;
    /// # use vite_static_html::*;
    /// #
    /// let html_lines = HtmlIntegration::new(MyViteStatic.boxed())
    ///     .import("src/main.tsx")
    ///     .build_lines();
    ///
    /// assert_eq!(html_lines.len(), 3);
    /// // `src/main.tsx` script tag, `src/shared.ts` preload and `src/style.scss` stylesheet.
    /// ```
    #[must_use]
    pub fn build_lines(self) -> Vec<String> {
        let mut html = Vec::new();

        let base = if self.manifest.base() == "/" {
            ""
        } else {
            &self.manifest.base()
        };

        for (filetype, preload) in self.preloads {
            html.push(format!(
                r#"<link rel="preload" href="{base}/{path}" as="{filetype}" type="{mimetype}"  crossorigin />"#,
                path = preload.file,
                mimetype = preload.mime_type
            ));
        }
        for style in self.stylesheets {
            html.push(format!(
                r#"<link rel="stylesheet" href="{base}/{style}" />"#
            ));
        }
        for script in self.scripts {
            html.push(format!(
                r#"<script type="module" src="{base}/{script}"></script>"#
            ));
        }
        for modulepreload in self.modulepreloads {
            html.push(format!(
                r#"<link rel="modulepreload" href="{base}/{modulepreload}" />"#
            ));
        }

        html
    }

    /// Builds HTML and returns [`String`].
    ///
    /// ```rust
    /// # use vite_static_shared::__tests::*;
    /// # use vite_static_html::*;
    /// #
    /// let html = HtmlIntegration::new(MyViteStatic.boxed())
    ///     .import("src/main.tsx")
    ///     .build();
    /// ```
    #[must_use]
    pub fn build(self) -> String {
        self.build_lines().join("\n")
    }
}

/// Internal functions for [`HtmlIntegration`].
impl HtmlIntegration<'_> {
    /// Internal import chunk function.
    fn import_as(&mut self, chunk: &str, is_dependency: bool) {
        let chunk = self
            .manifest
            .chunk_by_key(chunk)
            .unwrap_or_else(|| panic!(r#"failed to find chunk "{chunk}""#));

        for css in chunk.css.as_ref() {
            if !self.stylesheets.contains(css) {
                self.stylesheets.push(css.clone());
            }
        }

        for import in chunk.imports.as_ref() {
            let import_file = self
                .manifest
                .resolve_output(import)
                .unwrap_or_else(|| panic!(r#"failed to find imported chunk "{import}""#));

            if !self.modulepreloads.contains(&import_file) {
                self.import_as(import, true);
            }
        }

        if is_dependency {
            self.modulepreloads.push(chunk.file.clone());
        } else {
            self.scripts.push(chunk.file.clone());
        }
    }
}

impl Clone for HtmlIntegration<'_> {
    fn clone(&self) -> Self {
        Self {
            manifest: self.manifest.boxed(),
            stylesheets: self.stylesheets.clone(),
            scripts: self.scripts.clone(),
            modulepreloads: self.modulepreloads.clone(),
            preloads: self.preloads.clone(),
        }
    }
}