vize_l3 0.596.0

L3 - the Davinci reactivity IR (codename Impeto): flat scheduled ops with explicit state and effect edges
Documentation
//! Server eligibility attached to the existing shared enter/leave events.

use super::{SsrFacts, SsrPart, SsrRejection, SsrUnsupported};
use crate::decision::policy::BindingOwner;
use alloc::vec::Vec;
use vize_l0::{Span, id::NodeId, side_table::SideTable};
use vize_l2::{
    expr::ExprRef,
    lang::js::NativeSelectedSetup,
    op::{BindingOp, ElementOp, Namespace, Op},
    walk::NodeRef,
};
mod read;

pub(in crate::decision) struct SsrBuilder<'facts, 'owner, 'arena> {
    facts: SsrFacts<'owner, 'arena>,
    frames: Vec<(NodeId, Option<&'owner ElementOp<'arena>>, bool)>,
    roots: usize,
    non_comments: usize,
    root_element: Option<NodeId>,
    setup: Option<&'facts NativeSelectedSetup<'owner, 'arena>>,
    previous_root_text: Option<bool>,
    root_interpolation_seen: bool,
    nested_interpolation: Option<(NodeId, Span, NodeId)>,
}

impl<'facts, 'owner, 'arena> SsrBuilder<'facts, 'owner, 'arena> {
    pub fn new() -> Self {
        Self {
            facts: SsrFacts {
                parts: Vec::new(),
                unsupported: Vec::new(),
                inherit_attrs: None,
                fragment: false,
                expressions: SideTable::new(),
            },
            frames: Vec::new(),
            roots: 0,
            non_comments: 0,
            root_element: None,
            setup: None,
            previous_root_text: None,
            root_interpolation_seen: false,
            nested_interpolation: None,
        }
    }

    pub fn new_setup(setup: &'facts NativeSelectedSetup<'owner, 'arena>) -> Self {
        Self {
            setup: Some(setup),
            ..Self::new()
        }
    }

    pub fn enter(
        &mut self,
        node: NodeId,
        op: &'owner Op<'arena>,
        root: bool,
        original_values: &mut Option<
            crate::decision::attribute_value::OriginalAttributeCursor<'owner, 'arena>,
        >,
    ) {
        if root {
            if self.setup.is_some() {
                let current = match op {
                    Op::Text(_) => Some(false),
                    Op::Interpolation(_) => Some(true),
                    _ => None,
                };
                if matches!((self.previous_root_text, current), (Some(previous), Some(current))
                    if previous || current)
                {
                    self.reject(
                        node,
                        NodeRef::Op(op).span(),
                        SsrUnsupported::RootTextGrouping,
                    );
                }
                self.previous_root_text = current;
                if matches!(op, Op::Interpolation(_)) {
                    if self.root_interpolation_seen {
                        self.reject(node, NodeRef::Op(op).span(), SsrUnsupported::Operation);
                    }
                    self.root_interpolation_seen = true;
                }
            }
            self.roots += 1;
            if !matches!(op, Op::Comment(_)) {
                self.non_comments += 1;
                if matches!(op, Op::Element(_)) {
                    self.root_element = Some(node);
                }
            }
        }
        let span = NodeRef::Op(op).span();
        let (element, void) = match op {
            Op::Element(element) => {
                let void = is_void(element.tag);
                self.element(node, element, void, original_values);
                self.facts.parts.push(SsrPart::Open {
                    node,
                    element,
                    void,
                });
                (Some(element.as_ref()), void)
            }
            Op::Text(text) => {
                self.facts.parts.push(SsrPart::Text { node, text });
                (None, false)
            }
            Op::Comment(comment) => {
                let content = comment.content;
                if content.starts_with('>')
                    || content.starts_with("->")
                    || content.ends_with("<!-")
                    || ["<!--", "-->", "--!>"]
                        .iter()
                        .any(|token| content.contains(token))
                {
                    self.reject(node, span, SsrUnsupported::UnsafeComment);
                }
                self.facts.parts.push(SsrPart::Comment { node, comment });
                (None, false)
            }
            Op::Interpolation(interpolation) if self.setup.is_some() => {
                let parent = (!root)
                    .then(|| self.direct_interpolation_parent(interpolation.as_ref()))
                    .flatten();
                if !root && (parent.is_none() || self.nested_interpolation.is_some()) {
                    self.reject(node, span, SsrUnsupported::Operation);
                } else {
                    let expression = match (self.setup, interpolation.expression) {
                        (Some(setup), ExprRef::Js(expression)) => {
                            read::classify(setup, node, expression)
                        }
                        _ => Err(SsrUnsupported::Expression),
                    };
                    match expression {
                        Ok(expression) => {
                            self.facts.expressions.insert(node, expression);
                            self.facts.parts.push(SsrPart::Interpolation {
                                node,
                                interpolation,
                            });
                            if let Some(parent) = parent {
                                self.nested_interpolation = Some((node, span, parent));
                            }
                        }
                        Err(reason) => self.reject(node, span, reason),
                    }
                }
                (None, false)
            }
            _ => {
                self.reject(node, span, SsrUnsupported::Operation);
                (None, false)
            }
        };
        self.frames.push((node, element, void));
    }

    fn element(
        &mut self,
        node: NodeId,
        element: &'owner ElementOp<'arena>,
        void: bool,
        original_values: &mut Option<
            crate::decision::attribute_value::OriginalAttributeCursor<'owner, 'arena>,
        >,
    ) {
        if element.namespace != Namespace::Html {
            self.reject(node, element.span, SsrUnsupported::Namespace);
        }
        let mut tag = element.tag.bytes();
        if !tag.next().is_some_and(|byte| byte.is_ascii_lowercase())
            || !tag.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
        {
            self.reject(node, element.span, SsrUnsupported::ElementName);
        }
        if matches!(
            element.tag,
            "script" | "style" | "textarea" | "template" | "slot" | "pre" | "search"
        ) {
            self.reject(node, element.span, SsrUnsupported::ElementSemantics);
        }
        if void && !element.children.ops.is_empty() {
            self.reject(node, element.span, SsrUnsupported::VoidChildren);
        }
        let mut names = Vec::new();
        for (slot, attribute) in element.attributes.iter().enumerate() {
            crate::decision::attribute_value::observe(original_values, node, element, slot);
            let name = attribute.name;
            if name.is_empty()
                || !name.bytes().all(|byte| {
                    byte.is_ascii_lowercase()
                        || byte.is_ascii_digit()
                        || matches!(byte, b'-' | b'_' | b':' | b'.')
                })
            {
                self.reject(node, attribute.span, SsrUnsupported::AttributeName);
            }
            if matches!(
                name,
                "style"
                    | "key"
                    | "ref"
                    | "is"
                    | "slot"
                    | "value"
                    | "__proto__"
                    | "true-value"
                    | "false-value"
            ) || name.starts_with("on")
                || name.starts_with("v-")
                || name.starts_with(':')
                || name.starts_with('.')
            {
                self.reject(node, attribute.span, SsrUnsupported::AttributeSemantics);
            }
            if names.contains(&name) {
                self.reject(node, attribute.span, SsrUnsupported::DuplicateAttribute);
            }
            names.push(name);
        }
    }

    pub fn binding(&mut self, node: NodeId, binding: &BindingOp<'arena>, owner: BindingOwner) {
        // SSR has no event listeners on native HTML; cloak is compile-time only.
        if owner == BindingOwner::Element
            && matches!(binding, BindingOp::On(_) | BindingOp::VueCloak(_))
        {
            return;
        }
        self.reject(
            node,
            NodeRef::Binding(binding).span(),
            SsrUnsupported::Binding,
        );
    }

    pub fn leave(&mut self, _node: NodeId) {
        if let Some((node, Some(element), false)) = self.frames.pop() {
            self.facts.parts.push(SsrPart::Close { node, element });
        }
    }

    fn reject(&mut self, node: NodeId, span: Span, reason: SsrUnsupported) {
        self.facts
            .unsupported
            .push(SsrRejection { node, span, reason });
    }

    fn direct_interpolation_parent(
        &self,
        interpolation: &vize_l2::op::InterpolationOp<'arena>,
    ) -> Option<NodeId> {
        let [(parent_node, Some(parent), false)] = self.frames.as_slice() else {
            return None;
        };
        let [Op::Interpolation(child)] = parent.children.ops.as_slice() else {
            return None;
        };
        core::ptr::eq(child.as_ref(), interpolation).then_some(*parent_node)
    }

    pub fn finish(mut self) -> SsrFacts<'owner, 'arena> {
        // A visited prefix cannot authorize the final whole-root composition.
        // Retain the exact child facts but refuse output if any suffix changed it.
        if let Some((node, span, parent)) = self.nested_interpolation
            && (self.roots != 1 || self.non_comments != 1 || self.root_element != Some(parent))
        {
            self.reject(node, span, SsrUnsupported::Operation);
        }
        self.facts.fragment = self.roots > 1;
        self.facts.inherit_attrs = (self.non_comments == 1)
            .then_some(self.root_element)
            .flatten();
        self.facts
    }
}

fn is_void(tag: &str) -> bool {
    matches!(
        tag,
        "area"
            | "base"
            | "br"
            | "col"
            | "embed"
            | "hr"
            | "img"
            | "input"
            | "link"
            | "meta"
            | "param"
            | "source"
            | "track"
            | "wbr"
    )
}