vize_atelier_ssr 0.167.0

Vue SSR compiler for Vize
Documentation
//! Vue SSR compiler for Vize.
//!
//! This module provides SSR-specific compilation including:
//! - SSR code generation with template literals and `_push()` calls
//! - SSR-specific directive transforms (v-model, v-show)
//! - SSR slot rendering
//! - SSR component rendering
//! - SSR teleport and suspense handling
//!
//! ## Name Origin
//!
//! **Atelier** (/ˌætəlˈjeɪ/) is an artist's workshop or studio. The "ssr" atelier
//! specializes in server-side rendering output, producing HTML strings instead of
//! VNode trees.

#![allow(clippy::collapsible_match)]
#![cfg_attr(test, allow(clippy::disallowed_macros))]

pub mod codegen;
pub mod errors;
pub mod options;
pub mod transforms;

pub use codegen::{SsrCodegenContext, SsrCodegenResult};
pub use errors::SsrErrorCode;
pub use options::SsrCompilerOptions;
pub use transforms::{
    get_v_html_exp, get_v_model_exp, get_v_show_exp, get_v_text_exp, has_v_html, has_v_model,
    has_v_show, has_v_text,
};

// Re-export core types
pub use vize_atelier_core::{
    Allocator, CompilerError, Namespace, RootNode, RuntimeHelper, TemplateChildNode, ast,
    codegen as core_codegen, errors as core_errors, parser, runtime_helpers, tokenizer, transform,
};

use vize_atelier_core::{
    options::{ParserOptions, TransformOptions},
    parser::parse_with_options,
    transform::{transform as do_transform, transform_with_vue_parser_quirks},
};
use vize_carton::{Bump, String, profile};

/// Compile a Vue template for SSR with default options
pub fn compile_ssr<'a>(
    allocator: &'a Bump,
    source: &'a str,
) -> (RootNode<'a>, Vec<CompilerError>, SsrCodegenResult) {
    compile_ssr_with_options(allocator, source, SsrCompilerOptions::default())
}

/// Compile a Vue template for SSR with custom options
pub fn compile_ssr_with_options<'a>(
    allocator: &'a Bump,
    source: &'a str,
    options: SsrCompilerOptions,
) -> (RootNode<'a>, Vec<CompilerError>, SsrCodegenResult) {
    compile_ssr_inner(allocator, source, options, false)
}

/// Compile a Vue template for SSR with Vue parser quirk compatibility.
pub fn compile_ssr_with_vue_parser_quirks<'a>(
    allocator: &'a Bump,
    source: &'a str,
    options: SsrCompilerOptions,
) -> (RootNode<'a>, Vec<CompilerError>, SsrCodegenResult) {
    compile_ssr_inner(allocator, source, options, true)
}

fn compile_ssr_inner<'a>(
    allocator: &'a Bump,
    source: &'a str,
    options: SsrCompilerOptions,
    vue_parser_quirks: bool,
) -> (RootNode<'a>, Vec<CompilerError>, SsrCodegenResult) {
    let codegen_options = options.clone();

    // Create parser options
    let parser_opts = ParserOptions {
        is_void_tag: vize_carton::is_void_tag,
        is_native_tag: Some(vize_carton::is_native_tag),
        custom_renderer: options.custom_renderer,
        is_pre_tag: |tag| tag == "pre",
        get_namespace,
        comments: options.comments,
        ..ParserOptions::default()
    };

    // Parse
    let (mut root, errors) = profile!(
        "atelier.ssr.template.parse",
        parse_with_options(allocator, source, parser_opts)
    );

    // Parser-level diagnostics that are recoverable (e.g. duplicate
    // attribute) must NOT gate SSR codegen for the same reason as the
    // DOM compiler — see #958.
    let fatal_count = errors.iter().filter(|e| !e.is_recoverable()).count();
    if fatal_count > 0 {
        let codegen_result = SsrCodegenResult {
            code: String::default(),
            preamble: String::default(),
        };
        return (root, errors.to_vec(), codegen_result);
    }

    // Transform with SSR-specific settings
    // SSR always uses prefix identifiers and disables hoisting/caching
    let transform_opts = TransformOptions {
        prefix_identifiers: true, // SSR always uses prefix
        hoist_static: false,      // No hoisting in SSR
        cache_handlers: false,    // No caching in SSR
        scope_id: codegen_options.scope_id.clone(),
        ssr: true,
        is_ts: codegen_options.is_ts,
        inline: codegen_options.inline,
        custom_renderer: codegen_options.custom_renderer,
        binding_metadata: codegen_options.binding_metadata.clone(),
        ..Default::default()
    };
    let analysis = options.croquis.map(|c| &*allocator.alloc(*c));
    profile!(
        "atelier.ssr.template.transform",
        if vue_parser_quirks {
            transform_with_vue_parser_quirks(allocator, &mut root, transform_opts, analysis)
        } else {
            do_transform(allocator, &mut root, transform_opts, analysis)
        }
    );

    // SSR codegen
    let codegen_ctx = SsrCodegenContext::new(allocator, &codegen_options);
    let codegen_result = profile!("atelier.ssr.template.codegen", codegen_ctx.generate(&root));

    (root, errors.to_vec(), codegen_result)
}

/// Get the namespace for an element based on its parent
fn get_namespace(tag: &str, parent: Option<&str>) -> Namespace {
    if vize_carton::is_svg_tag(tag) {
        return Namespace::Svg;
    }
    if vize_carton::is_math_ml_tag(tag) {
        return Namespace::MathMl;
    }

    // Inherit namespace from parent
    if let Some(parent_tag) = parent {
        if vize_carton::is_svg_tag(parent_tag) && tag != "foreignObject" {
            return Namespace::Svg;
        }
        if vize_carton::is_math_ml_tag(parent_tag)
            && tag != "annotation-xml"
            && tag != "foreignObject"
        {
            return Namespace::MathMl;
        }
    }

    Namespace::Html
}

#[cfg(test)]
mod tests {
    use super::{Bump, SsrCompilerOptions, compile_ssr, compile_ssr_with_options};

    #[test]
    fn test_compile_simple_element() {
        let allocator = Bump::new();
        let (root, errors, result) = compile_ssr(&allocator, "<div>hello</div>");

        assert!(errors.is_empty());
        assert_eq!(root.children.len(), 1);
        insta::assert_snapshot!(result.code.as_str());
    }

    #[test]
    fn test_compile_interpolation() {
        let allocator = Bump::new();
        let (_, errors, result) = compile_ssr(&allocator, "<div>{{ msg }}</div>");

        assert!(errors.is_empty());
        insta::assert_snapshot!(result.code.as_str());
    }

    #[test]
    fn test_scoped_dynamic_component_keeps_scope_id() {
        let allocator = Bump::new();
        let (_, errors, result) = compile_ssr_with_options(
            &allocator,
            r#"<component :is="tag"><span>Logo</span></component>"#,
            SsrCompilerOptions {
                scope_id: Some("data-v-test".into()),
                ..SsrCompilerOptions::default()
            },
        );

        assert!(errors.is_empty());
        assert!(
            result
                .code
                .contains(r#"_mergeProps({  }, { "data-v-test": "" })"#)
                || result.code.contains(r#"{ "data-v-test": "" }"#),
            "{}",
            result.code
        );
        assert!(
            result
                .code
                .contains(r#"_createElementVNode("span", { "data-v-test": "" }"#),
            "{}",
            result.code
        );
    }

    #[test]
    fn test_scoped_component_keeps_scope_id() {
        let allocator = Bump::new();
        let (_, errors, result) = compile_ssr_with_options(
            &allocator,
            r#"<NuxtLink to="/news" class="news__link"><span>News</span></NuxtLink>"#,
            SsrCompilerOptions {
                scope_id: Some("data-v-news".into()),
                ..SsrCompilerOptions::default()
            },
        );

        assert!(errors.is_empty());
        assert!(
            result.code.contains(r#""data-v-news": """#),
            "{}",
            result.code
        );
        assert!(
            result.code.contains(r#"class: "news__link""#),
            "{}",
            result.code
        );
    }

    #[test]
    fn test_ssr_v_model_textarea_renders_bound_value() {
        // Regression for #962: `<textarea v-model="x">` must render `x` as
        // escaped text content. The previous SSR path emitted
        // `<textarea></textarea>` with no body, losing the initial value
        // and triggering hydration mismatches.
        let allocator = Bump::new();
        let (_, errors, result) = compile_ssr(&allocator, r#"<textarea v-model="x"></textarea>"#);
        assert!(errors.is_empty(), "{errors:?}");
        assert!(
            result.code.contains("_ssrInterpolate(_ctx.x)"),
            "expected textarea body to interpolate the model value, got:\n{}",
            result.code
        );
    }

    #[test]
    fn test_ssr_v_model_select_marks_matching_option_selected() {
        // Regression for #962: `<select v-model="x">` must render the
        // matching `<option>` with `selected` set, not silently drop the
        // bound value.
        let allocator = Bump::new();
        let (_, errors, result) = compile_ssr(
            &allocator,
            r#"<select v-model="x"><option value="a">A</option><option value="b">B</option></select>"#,
        );
        assert!(errors.is_empty(), "{errors:?}");
        assert!(
            result.code.contains("_ssrLooseEqual(_ctx.x, \"a\")"),
            "expected loose-equal for option a, got:\n{}",
            result.code
        );
        assert!(
            result.code.contains("_ssrLooseEqual(_ctx.x, \"b\")"),
            "expected loose-equal for option b, got:\n{}",
            result.code
        );
        assert!(
            result.code.contains("\" selected\""),
            "expected ` selected` literal, got:\n{}",
            result.code
        );
    }

    #[test]
    fn test_dynamic_slot_outlet_name_stays_expression() {
        let allocator = Bump::new();
        let (_, errors, result) = compile_ssr_with_options(
            &allocator,
            r#"<Parent><slot :name="((item.slot || 'item') as keyof Slots)" :item="item" /></Parent>"#,
            SsrCompilerOptions {
                is_ts: true,
                ..SsrCompilerOptions::default()
            },
        );

        assert!(errors.is_empty());
        assert!(
            result
                .code
                .contains(r#"_ssrRenderSlot(_ctx.$slots, _ctx.item.slot || "item""#),
            "{}",
            result.code
        );
        assert!(
            result
                .code
                .contains(r#"_renderSlot(_ctx.$slots, _ctx.item.slot || "item""#),
            "{}",
            result.code
        );
    }
}