vize_atelier_ssr 0.258.0

Vue SSR compiler for Vize
Documentation
//! SSR code generation.
//!
//! SSR code generation produces JavaScript that uses template literals and `_push()` calls
//! to build HTML strings on the server side.

mod component_binding;
mod element;
pub(crate) mod helpers;
mod scope_prefix;

use crate::options::SsrCompilerOptions;
use vize_atelier_core::{RootNode, RuntimeHelper, TemplateChildNode};
use vize_carton::{Bump, FxHashSet, SmallVec, String, ToCompactString, camelize, capitalize};

/// SSR codegen result
#[derive(Debug, Default)]
pub struct SsrCodegenResult {
    /// Generated render function code
    pub code: String,
    /// Import preamble
    pub preamble: String,
}

/// A part of a template literal
#[derive(Debug)]
pub(crate) enum TemplatePart {
    /// Static string content
    Static(String),
    /// Dynamic expression
    Dynamic(String),
}

/// SSR codegen context
pub struct SsrCodegenContext<'a> {
    #[allow(dead_code)]
    pub(crate) allocator: &'a Bump,
    pub(crate) options: &'a SsrCompilerOptions,
    /// Output buffer
    pub(crate) code: Vec<u8>,
    /// Indent level
    pub(crate) indent_level: u32,
    /// Used SSR helpers
    pub(crate) ssr_helpers: FxHashSet<RuntimeHelper>,
    /// Used core helpers (from vue)
    pub(crate) core_helpers: FxHashSet<RuntimeHelper>,
    /// Current template literal parts being accumulated
    pub(crate) current_template_parts: SmallVec<[TemplatePart; 8]>,
    /// Whether we have an open _push call
    #[allow(dead_code)]
    pub(crate) has_open_push: bool,
    /// Whether currently within a slot scope
    #[allow(dead_code)]
    pub(crate) with_slot_scope_id: bool,
    /// Template-local identifiers from v-for and scoped slots.
    pub(crate) scoped_params: std::vec::Vec<FxHashSet<String>>,
    /// Stack of `v-model` expressions on currently-open `<select>` ancestors.
    /// When non-empty, an `<option>` child uses the top entry to render
    /// `selected` for the matching value. Vue's SSR runtime tracks the same
    /// state in JS via `_temp`; we thread it through codegen instead. (#962)
    pub(crate) select_v_model_stack: std::vec::Vec<String>,
}

impl<'a> SsrCodegenContext<'a> {
    pub fn new(allocator: &'a Bump, options: &'a SsrCompilerOptions) -> Self {
        Self {
            allocator,
            options,
            code: Vec::with_capacity(1024),
            indent_level: 0,
            ssr_helpers: FxHashSet::default(),
            core_helpers: FxHashSet::default(),
            current_template_parts: SmallVec::new(),
            has_open_push: false,
            with_slot_scope_id: false,
            scoped_params: std::vec::Vec::new(),
            select_v_model_stack: std::vec::Vec::new(),
        }
    }

    /// Generate SSR code from the AST
    pub fn generate(mut self, root: &RootNode<'a>) -> SsrCodegenResult {
        // Transforms can rewrite setup bindings to `_unref(...)` in SSR
        // expressions. SSR codegen does not walk helper symbols the same way as
        // DOM codegen, so carry the root helper through explicitly.
        if root.helpers.contains(&RuntimeHelper::Unref) {
            self.use_core_helper(RuntimeHelper::Unref);
        }

        // Check if this is a fragment (multiple non-text children)
        let is_fragment = root.children.len() > 1
            && root
                .children
                .iter()
                .any(|c| !matches!(c, TemplateChildNode::Text(_)));

        // Generate function signature
        self.push("function ssrRender(_ctx, _push, _parent, _attrs");
        if self.options.binding_metadata.is_some() {
            self.push(", $props, $setup, $data, $options");
        }
        if self.options.scope_id.is_some() {
            self.push(", _scopeId");
        }
        self.push(") {\n");
        self.indent_level += 1;

        // Inject CSS vars if present
        if let Some(css_vars) = &self.options.ssr_css_vars {
            self.push_indent();
            self.push("const _cssVars = { style: ");
            self.push(css_vars);
            self.push(" }\n");
        }

        // Process children
        self.process_root_children(&root.children, is_fragment, false, false);

        // Flush any remaining template literal
        self.flush_push();

        self.indent_level -= 1;
        self.push("}\n");

        // Build preamble with imports
        let preamble = self.build_preamble();

        SsrCodegenResult {
            // SAFETY: `self.code` is filled exclusively through `push(&str)`,
            // `push_indent`, and helpers that append ASCII punctuation around
            // already-valid template/source strings. No caller can inject raw
            // bytes into the buffer, so the Vec<u8> invariant is "always valid
            // UTF-8". Keeping this unchecked conversion avoids validating the
            // complete generated SSR module after every compile.
            code: unsafe { String::from_utf8_unchecked(self.code) },
            preamble,
        }
    }

    /// Push static string content to the current template literal.
    ///
    /// Adjacent static chunks are coalesced before `flush_push`, so deeply nested
    /// SSR templates emit fewer `_push(...)` calls and allocate fewer intermediate
    /// strings while preserving dynamic expression boundaries.
    pub(crate) fn push_string_part_static(&mut self, s: &str) {
        if let Some(TemplatePart::Static(last)) = self.current_template_parts.last_mut() {
            last.push_str(s);
        } else {
            self.current_template_parts
                .push(TemplatePart::Static(s.to_compact_string()));
        }
    }

    /// Push dynamic expression to the current template literal
    pub(crate) fn push_string_part_dynamic(&mut self, expr: &str) {
        self.current_template_parts
            .push(TemplatePart::Dynamic(expr.to_compact_string()));
    }

    /// Flush the current template literal as a _push() call.
    ///
    /// Template parts are buffered as a `SmallVec` because most elements contain
    /// only a handful of static/dynamic pieces. Taking the buffer here lets the
    /// context reuse the allocation for subsequent chunks while writing directly
    /// into the final byte buffer.
    pub(crate) fn flush_push(&mut self) {
        if self.current_template_parts.is_empty() {
            return;
        }

        // Take ownership of parts to avoid borrow issues
        let parts = std::mem::take(&mut self.current_template_parts);

        self.push_indent();
        self.push("_push(`");

        for part in &parts {
            match part {
                TemplatePart::Static(s) => {
                    self.push_template_static(s);
                }
                TemplatePart::Dynamic(expr) => {
                    self.push("${");
                    self.push(expr);
                    self.push("}");
                }
            }
        }

        self.push("`)\n");
    }

    fn push_template_static(&mut self, value: &str) {
        let bytes = value.as_bytes();
        let mut start = 0;
        let mut index = 0;

        while index < bytes.len() {
            match bytes[index] {
                b'`' => {
                    self.code.extend_from_slice(&bytes[start..index]);
                    self.code.extend_from_slice(b"\\`");
                    index += 1;
                    start = index;
                }
                b'$' if index + 1 < bytes.len() && bytes[index + 1] == b'{' => {
                    self.code.extend_from_slice(&bytes[start..index]);
                    self.code.extend_from_slice(b"\\${");
                    index += 2;
                    start = index;
                }
                _ => {
                    index += 1;
                }
            }
        }

        self.code.extend_from_slice(&bytes[start..]);
    }

    /// Use an SSR helper
    pub(crate) fn use_ssr_helper(&mut self, helper: RuntimeHelper) {
        self.ssr_helpers.insert(helper);
    }

    /// Use a core helper (from vue)
    pub(crate) fn use_core_helper(&mut self, helper: RuntimeHelper) {
        self.core_helpers.insert(helper);
    }

    pub(crate) fn is_self_component_reference(&self, component: &str) -> bool {
        let Some(component_name) = self.options.component_name.as_deref() else {
            return false;
        };

        if component == component_name {
            return true;
        }

        let camel = camelize(component);
        let pascal = capitalize(camel.as_str());
        pascal == component_name
    }

    pub(crate) fn push_scoped_params(&mut self, params: FxHashSet<String>) {
        self.scoped_params.push(params);
    }

    pub(crate) fn pop_scoped_params(&mut self) {
        self.scoped_params.pop();
    }

    pub(crate) fn strip_ctx_for_scoped_params(&self, content: &str) -> String {
        scope_prefix::strip_scope_prefixes_for_scoped_params(&self.scoped_params, content)
    }

    /// Push raw code to the buffer
    pub(crate) fn push(&mut self, s: &str) {
        self.code.extend_from_slice(s.as_bytes());
    }

    /// Push indentation
    pub(crate) fn push_indent(&mut self) {
        for _ in 0..self.indent_level {
            self.code.extend_from_slice(b"  ");
        }
    }

    /// Build the preamble with imports
    fn build_preamble(&self) -> String {
        let mut preamble = String::default();

        // SSR helpers from @vue/server-renderer
        if !self.ssr_helpers.is_empty() {
            preamble.push_str("import { ");
            let mut ssr_helpers: Vec<_> = self.ssr_helpers.iter().copied().collect();
            ssr_helpers.sort();
            push_helper_imports(&mut preamble, &ssr_helpers);
            preamble.push_str(" } from \"@vue/server-renderer\"\n");
        }

        // Core helpers from vue
        if !self.core_helpers.is_empty() {
            preamble.push_str("import { ");
            let mut core_helpers: Vec<_> = self.core_helpers.iter().copied().collect();
            core_helpers.sort();
            push_helper_imports(&mut preamble, &core_helpers);
            preamble.push_str(" } from \"vue\"\n");
        }

        preamble
    }
}

fn push_helper_imports(out: &mut String, helpers: &[RuntimeHelper]) {
    for (index, helper) in helpers.iter().enumerate() {
        if index > 0 {
            out.push_str(", ");
        }
        let name = helper.name();
        out.push_str(name);
        out.push_str(" as _");
        out.push_str(name);
    }
}

#[cfg(test)]
mod tests {
    use super::SsrCodegenResult;
    use super::helpers::{escape_html, escape_html_attr};

    #[test]
    fn test_escape_html() {
        assert_eq!(escape_html("<div>"), "&lt;div&gt;");
        assert_eq!(escape_html("a & b"), "a &amp; b");
        assert_eq!(escape_html("\"hello\""), "&quot;hello&quot;");
    }

    #[test]
    fn test_escape_html_all_special_chars() {
        assert_eq!(escape_html("&"), "&amp;");
        assert_eq!(escape_html("<"), "&lt;");
        assert_eq!(escape_html(">"), "&gt;");
        assert_eq!(escape_html("\""), "&quot;");
        assert_eq!(escape_html("'"), "&#39;");
    }

    #[test]
    fn test_escape_html_no_special() {
        assert_eq!(escape_html("hello world"), "hello world");
        assert_eq!(escape_html("abc123"), "abc123");
    }

    #[test]
    fn test_escape_html_attr() {
        assert_eq!(escape_html_attr("hello\"world"), "hello&quot;world");
        assert_eq!(escape_html_attr("a & b"), "a &amp; b");
    }

    #[test]
    fn test_escape_html_attr_preserves_angle_brackets() {
        // In attribute context, < and > do not need escaping
        assert_eq!(escape_html_attr("<foo>"), "<foo>");
        assert_eq!(escape_html_attr("a > b"), "a > b");
    }

    #[test]
    fn test_escape_html_attr_no_special() {
        assert_eq!(escape_html_attr("hello"), "hello");
    }

    #[test]
    fn test_ssr_codegen_result_default() {
        let result = SsrCodegenResult::default();
        assert!(result.code.is_empty());
        assert!(result.preamble.is_empty());
    }
}