#[cfg(feature = "tracing")]
use crate::format::SlotSource;
use crate::format::{IrError, IslandTrigger, Opcode, PropsMode};
use crate::parser::IrModule;
use crate::slot::{SlotData, SlotValue};
struct IslandPending {
id: u16,
component_name: String,
inline_props: Option<String>,
trigger: IslandTrigger,
}
pub const INLINE_PROPS_MAX_BYTES: usize = 1024;
pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
pub const DEFAULT_MAX_LIST_ITERATIONS: usize = 50_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WalkLimits {
pub max_output_bytes: usize,
pub max_list_iterations: usize,
}
impl Default for WalkLimits {
fn default() -> Self {
WalkLimits {
max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
max_list_iterations: DEFAULT_MAX_LIST_ITERATIONS,
}
}
}
struct WalkState {
list_depth: u8,
pending_island: Option<IslandPending>,
pending_list_key: Option<String>,
script_tag_props: std::collections::BTreeMap<u16, serde_json::Value>,
pending_tag_close: bool,
pending_tag_name: String,
max_output_bytes: usize,
list_iterations: usize,
max_list_iterations: usize,
slot_journal: Vec<(u16, SlotValue)>,
force_inline_props: bool,
}
impl WalkState {
fn new(limits: WalkLimits) -> Self {
WalkState {
list_depth: 0,
pending_island: None,
pending_list_key: None,
script_tag_props: std::collections::BTreeMap::new(),
pending_tag_close: false,
pending_tag_name: String::new(),
max_output_bytes: limits.max_output_bytes,
list_iterations: 0,
max_list_iterations: limits.max_list_iterations,
slot_journal: Vec::new(),
force_inline_props: false,
}
}
fn journal_write(&mut self, slot_id: u16, previous: SlotValue) {
if self.list_depth > 0 {
self.slot_journal.push((slot_id, previous));
}
}
fn unwind_slot_journal(&mut self, slots: &mut SlotData, mark: usize) {
while self.slot_journal.len() > mark {
if let Some((slot_id, previous)) = self.slot_journal.pop() {
slots.set(slot_id, previous);
}
}
}
}
#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
fn check_slot_source(module: &IrModule, slot_id: u16, value: &SlotValue) {
#[cfg(feature = "tracing")]
if let Some(entry) = module.slot_entry(slot_id) {
if entry.source == SlotSource::Server && matches!(value, SlotValue::Null) {
tracing::warn!(
slot_id = slot_id,
"Server-sourced slot has no value at render time — handler may have a bug"
);
}
}
}
pub fn walk_to_html(module: &IrModule, slots: &SlotData) -> Result<String, IrError> {
walk_to_html_with_limits(module, slots, WalkLimits::default())
}
pub fn walk_to_html_with_limits(
module: &IrModule,
slots: &SlotData,
limits: WalkLimits,
) -> Result<String, IrError> {
let ops = &module.opcodes;
let len = ops.len();
let mut out = String::with_capacity(len.saturating_mul(4).min(1 << 20));
let mut state = WalkState::new(limits);
let mut slots_mut = slots.clone();
walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)?;
if !state.script_tag_props.is_empty() {
let json =
serde_json::to_string(&state.script_tag_props).unwrap_or_else(|_| "{}".to_string());
push_checked(
&mut out,
"<script id=\"__forma_islands\" type=\"application/json\">",
state.max_output_bytes,
)?;
push_json_script_body(&mut out, &json, state.max_output_bytes)?;
push_checked(&mut out, "</script>", state.max_output_bytes)?;
}
Ok(out)
}
fn push_json_script_body(out: &mut String, json: &str, limit: usize) -> Result<(), IrError> {
for ch in json.chars() {
match ch {
'<' => push_checked(out, "\\u003c", limit)?,
'>' => push_checked(out, "\\u003e", limit)?,
'\u{2028}' => push_checked(out, "\\u2028", limit)?,
'\u{2029}' => push_checked(out, "\\u2029", limit)?,
_ => {
if out.len() >= limit {
return Err(IrError::OutputLimitExceeded { limit });
}
out.push(ch);
}
}
}
Ok(())
}
pub fn walk_island(module: &IrModule, slots: &SlotData, island_id: u16) -> Result<String, IrError> {
walk_island_with_limits(module, slots, island_id, WalkLimits::default())
}
pub fn walk_island_with_limits(
module: &IrModule,
slots: &SlotData,
island_id: u16,
limits: WalkLimits,
) -> Result<String, IrError> {
let entry = module
.island_entry(island_id)
.ok_or(IrError::IslandNotFound(island_id))?;
let byte_offset = entry.byte_offset as usize;
let ops = &module.opcodes;
if byte_offset >= ops.len() {
return Err(IrError::BufferTooShort {
expected: byte_offset.saturating_add(1),
actual: ops.len(),
});
}
let opcode = Opcode::from_byte(ops[byte_offset])?;
if opcode != Opcode::IslandStart {
return Err(IrError::InvalidOpcode(ops[byte_offset]));
}
let content_start = byte_offset.saturating_add(3);
if content_start > ops.len() {
return Err(IrError::BufferTooShort {
expected: content_start,
actual: ops.len(),
});
}
let mut out = String::with_capacity(256);
let mut state = WalkState::new(limits);
state.force_inline_props = true;
let mut slots_mut = slots.clone();
let component_name = module.strings.get(entry.name_str_idx)?.to_string();
let inline_props = match entry.props_mode {
PropsMode::Inline | PropsMode::ScriptTag => {
if entry.slot_ids.is_empty() {
None
} else {
let props = build_island_props(module, &slots_mut, &entry.slot_ids);
Some(serde_json::to_string(&props).unwrap_or_else(|_| "{}".to_string()))
}
}
PropsMode::Deferred => None,
};
state.pending_island = Some(IslandPending {
id: island_id,
component_name,
inline_props,
trigger: entry.trigger,
});
walk_range_until_island_end(
module,
&mut slots_mut,
ops,
content_start,
island_id,
&mut out,
&mut state,
0,
)?;
Ok(out)
}
const MAX_LIST_DEPTH: u8 = 4;
const MAX_RECURSION_DEPTH: usize = 64;
enum WalkMode {
Range { end: usize },
UntilIslandEnd { target_island_id: u16 },
}
#[allow(clippy::too_many_arguments)]
fn walk_range(
module: &IrModule,
slots: &mut SlotData,
ops: &[u8],
start: usize,
end: usize,
out: &mut String,
state: &mut WalkState,
depth: usize,
) -> Result<(), IrError> {
walk_range_impl(
module,
slots,
ops,
start,
WalkMode::Range { end },
out,
state,
depth,
)
}
#[allow(clippy::too_many_arguments)]
fn walk_range_until_island_end(
module: &IrModule,
slots: &mut SlotData,
ops: &[u8],
start: usize,
target_island_id: u16,
out: &mut String,
state: &mut WalkState,
depth: usize,
) -> Result<(), IrError> {
walk_range_impl(
module,
slots,
ops,
start,
WalkMode::UntilIslandEnd { target_island_id },
out,
state,
depth,
)
}
#[allow(clippy::too_many_arguments)]
fn walk_range_impl(
module: &IrModule,
slots: &mut SlotData,
ops: &[u8],
start: usize,
mode: WalkMode,
out: &mut String,
state: &mut WalkState,
depth: usize,
) -> Result<(), IrError> {
if depth > MAX_RECURSION_DEPTH {
return Err(IrError::RecursionLimitExceeded);
}
let strings = &module.strings;
let mut pos = start;
let limit = state.max_output_bytes;
let mut fallback_stack: Vec<usize> = Vec::new();
let loop_bound = match mode {
WalkMode::Range { end } => end,
WalkMode::UntilIslandEnd { .. } => usize::MAX, };
let region_limit = match mode {
WalkMode::Range { end } => end.min(ops.len()),
WalkMode::UntilIslandEnd { .. } => ops.len(),
};
while pos < loop_bound {
if out.len() > limit {
return Err(IrError::OutputLimitExceeded { limit });
}
if pos >= ops.len() {
match mode {
WalkMode::Range { .. } => {
return Err(IrError::BufferTooShort {
expected: pos + 1,
actual: ops.len(),
});
}
WalkMode::UntilIslandEnd { .. } => break, }
}
let opcode = Opcode::from_byte(ops[pos])?;
pos += 1;
if state.pending_tag_close && opcode != Opcode::DynAttr {
push_checked(out, ">", limit)?;
state.pending_tag_close = false;
}
match opcode {
Opcode::OpenTag | Opcode::VoidTag => {
let (tag_str_idx, attrs, new_pos) = read_tag_with_attrs(ops, pos, strings)?;
let tag = strings.get(tag_str_idx)?;
emit_open_tag(out, tag, &attrs, state, limit)?;
pos = new_pos;
}
Opcode::CloseTag => {
let str_idx = read_u32(ops, pos)?;
pos += 4;
let tag = strings.get(str_idx)?;
if !is_valid_name(tag) {
return Err(IrError::InvalidName(tag.to_string()));
}
push_checked(out, "</", limit)?;
push_checked(out, tag, limit)?;
push_checked(out, ">", limit)?;
}
Opcode::Text => {
let str_idx = read_u32(ops, pos)?;
pos += 4;
let text = strings.get(str_idx)?;
push_escaped_html(out, text, limit)?;
}
Opcode::DynText => {
let slot_id = read_u16(ops, pos)?;
pos += 2;
let marker_id = read_u16(ops, pos)?;
pos += 2;
check_slot_source(module, slot_id, slots.get(slot_id));
let value = slots.get(slot_id).to_text();
push_checked(out, "<!--f:t", limit)?;
push_u16(out, marker_id, limit)?;
push_checked(out, "-->", limit)?;
if value.is_empty() {
push_checked(out, "\u{200B}", limit)?;
} else {
push_escaped_html(out, &value, limit)?;
}
push_checked(out, "<!--/f:t", limit)?;
push_u16(out, marker_id, limit)?;
push_checked(out, "-->", limit)?;
}
Opcode::DynAttr => {
let attr_str_idx = read_u32(ops, pos)?;
let slot_id = read_u16(ops, pos + 4)?;
pos += 6;
check_slot_source(module, slot_id, slots.get(slot_id));
if state.pending_tag_close {
let attr_name = strings.get(attr_str_idx)?;
let text;
let value = match slots.get(slot_id) {
SlotValue::Bool(false) | SlotValue::Null => None,
SlotValue::Bool(true) => Some(AttrValue::Bare),
other => {
text = other.to_text();
Some(AttrValue::Valued(text.as_str()))
}
};
if let Some(value) = value {
let tag = std::mem::take(&mut state.pending_tag_name);
emit_attr(out, &tag, attr_name, value, limit)?;
state.pending_tag_name = tag;
}
}
}
Opcode::IslandStart => {
let island_id = read_u16(ops, pos)?;
pos += 2;
if let Some(outer) = &state.pending_island {
return Err(IrError::NestedIslandRoot {
outer: outer.id,
inner: island_id,
});
}
push_checked(out, "<!--f:i", limit)?;
push_u16(out, island_id, limit)?;
push_checked(out, "-->", limit)?;
if let Some(entry) = module.island_entry(island_id) {
let component_name = strings.get(entry.name_str_idx)?.to_string();
let inline_props = match entry.props_mode {
PropsMode::Inline => {
if entry.slot_ids.is_empty() {
None
} else {
let props = build_island_props(module, slots, &entry.slot_ids);
let json = serde_json::to_string(&props)
.unwrap_or_else(|_| "{}".to_string());
if json.len() > INLINE_PROPS_MAX_BYTES && !state.force_inline_props
{
state
.script_tag_props
.insert(island_id, serde_json::Value::Object(props));
None
} else {
Some(json)
}
}
}
PropsMode::ScriptTag if state.force_inline_props => {
if entry.slot_ids.is_empty() {
None
} else {
let props = build_island_props(module, slots, &entry.slot_ids);
Some(
serde_json::to_string(&props)
.unwrap_or_else(|_| "{}".to_string()),
)
}
}
PropsMode::ScriptTag => {
if !entry.slot_ids.is_empty() {
let props = build_island_props(module, slots, &entry.slot_ids);
state
.script_tag_props
.insert(island_id, serde_json::Value::Object(props));
}
None
}
PropsMode::Deferred => None,
};
state.pending_island = Some(IslandPending {
id: island_id,
component_name,
inline_props,
trigger: entry.trigger,
});
} else {
#[cfg(feature = "tracing")]
tracing::warn!(
island_id = island_id,
"ISLAND_START names an island that is not in the island table — \
markers emitted but no data-forma-* attributes, so it will not hydrate \
(module was not validated)"
);
}
}
Opcode::IslandEnd => {
let island_id = read_u16(ops, pos)?;
pos += 2;
if state.pending_island.take().is_some() {
#[cfg(feature = "tracing")]
tracing::warn!(
island_id = island_id,
"ISLAND_START was not followed by an element — island attributes dropped, \
this island will not hydrate (compiler should emit a shell element)"
);
}
if let WalkMode::UntilIslandEnd { target_island_id } = mode {
if island_id == target_island_id {
if state.pending_tag_close {
push_checked(out, ">", limit)?;
state.pending_tag_close = false;
}
return Ok(());
}
}
push_checked(out, "<!--/f:i", limit)?;
push_u16(out, island_id, limit)?;
push_checked(out, "-->", limit)?;
}
Opcode::Comment => {
let str_idx = read_u32(ops, pos)?;
pos += 4;
let text = strings.get(str_idx)?;
push_checked(out, "<!--", limit)?;
if text.starts_with('>') || text.starts_with("->") {
push_checked(out, " ", limit)?;
}
push_checked(out, &text.replace("--", "--"), limit)?;
push_checked(out, "-->", limit)?;
}
Opcode::ShowIf => {
let slot_id = read_u16(ops, pos)?;
let then_len = read_u32(ops, pos + 2)? as usize;
let else_len = read_u32(ops, pos + 6)? as usize;
pos += 10;
check_slot_source(module, slot_id, slots.get(slot_id));
let condition = slots.get(slot_id).as_bool();
let then_start = pos;
let then_end = bounded_offset(pos, then_len, region_limit)?;
if then_end >= region_limit || ops[then_end] != Opcode::ShowElse as u8 {
return Err(IrError::InvalidSection);
}
let else_start = then_end + 1; let else_end = bounded_offset(else_start, else_len, region_limit)?;
push_checked(out, "<!--f:s", limit)?;
push_u16(out, slot_id, limit)?;
push_checked(out, "-->", limit)?;
if condition {
walk_range(
module,
slots,
ops,
then_start,
then_end,
out,
state,
depth + 1,
)?;
} else {
walk_range(
module,
slots,
ops,
else_start,
else_end,
out,
state,
depth + 1,
)?;
}
push_checked(out, "<!--/f:s", limit)?;
push_u16(out, slot_id, limit)?;
push_checked(out, "-->", limit)?;
pos = else_end;
}
Opcode::ShowElse => {
}
Opcode::Switch => {
let slot_id = read_u16(ops, pos)?;
let case_count = read_u16(ops, pos + 2)? as usize;
pos += 4;
let mut cases =
Vec::with_capacity(case_count.min(ops.len().saturating_sub(pos) / 8));
for _ in 0..case_count {
let val_str_idx = read_u32(ops, pos)?;
let body_len = read_u32(ops, pos + 4)? as usize;
cases.push((val_str_idx, body_len));
pos += 8;
}
check_slot_source(module, slot_id, slots.get(slot_id));
let slot_text = slots.get(slot_id).to_text();
let mut body_pos = pos;
let mut matched = false;
for (val_str_idx, body_len) in &cases {
let case_end = bounded_offset(body_pos, *body_len, region_limit)?;
let case_val = strings.get(*val_str_idx)?;
if !matched && case_val == slot_text {
matched = true;
walk_range(
module,
slots,
ops,
body_pos,
case_end,
out,
state,
depth + 1,
)?;
}
body_pos = case_end;
}
pos = body_pos; }
Opcode::List => {
let slot_id = read_u16(ops, pos)?;
let item_slot_id = read_u16(ops, pos + 2)?;
let body_len = read_u32(ops, pos + 4)? as usize;
pos += 8;
check_slot_source(module, slot_id, slots.get(slot_id));
let body_start = pos;
let body_end = bounded_offset(pos, body_len, region_limit)?;
let list_marker_id = slot_id;
push_checked(out, "<!--f:l", limit)?;
push_u16(out, list_marker_id, limit)?;
push_checked(out, "-->", limit)?;
let items = match slots.get(slot_id) {
SlotValue::Array(a) => Some(a.clone()),
_ => None,
};
if let Some(items) = items.filter(|a| !a.is_empty()) {
state.list_depth += 1;
if state.list_depth > MAX_LIST_DEPTH {
return Err(IrError::ListDepthExceeded {
max: MAX_LIST_DEPTH,
});
}
for item in items.iter() {
state.list_iterations += 1;
if state.list_iterations > state.max_list_iterations {
return Err(IrError::ListBudgetExceeded {
max: state.max_list_iterations,
});
}
let mark = state.slot_journal.len();
let previous = slots.replace(item_slot_id, item.clone());
state.slot_journal.push((item_slot_id, previous));
let walked = walk_range(
module,
slots,
ops,
body_start,
body_end,
out,
state,
depth + 1,
);
state.unwind_slot_journal(slots, mark);
walked?;
state.pending_list_key = None;
}
state.list_depth -= 1;
}
state.pending_list_key = None;
push_checked(out, "<!--/f:l", limit)?;
push_u16(out, list_marker_id, limit)?;
push_checked(out, "-->", limit)?;
pos = body_end;
}
Opcode::TryStart => {
let fallback_len = read_u32(ops, pos)? as usize;
pos += 4;
fallback_stack.push(fallback_len);
}
Opcode::Fallback => {
if let Some(skip) = fallback_stack.pop() {
pos = bounded_offset(pos, skip, region_limit)?;
}
}
Opcode::Preload => {
if pos >= ops.len() {
return Err(IrError::BufferTooShort {
expected: pos + 1,
actual: ops.len(),
});
}
let resource_type = ops[pos];
let url_str_idx = read_u32(ops, pos + 1)?;
let url = strings.get(url_str_idx)?;
pos += 5;
let (as_val, type_attr) = match resource_type {
1 => ("font", " type=\"font/woff2\" crossorigin"),
2 => ("style", ""),
3 => ("script", ""),
4 => ("image", ""),
_ => ("fetch", ""),
};
push_checked(out, "<link rel=\"preload\" href=\"", limit)?;
push_escaped_attr(out, url, limit)?;
push_checked(out, "\" as=\"", limit)?;
push_checked(out, as_val, limit)?;
push_checked(out, "\"", limit)?;
push_checked(out, type_attr, limit)?;
push_checked(out, ">\n", limit)?;
}
Opcode::ListItemKey => {
let key_str_idx = read_u32(ops, pos)?;
pos += 4;
let key_val = strings.get(key_str_idx)?;
state.pending_list_key = Some(key_val.to_string());
}
Opcode::Prop => {
let src_slot_id = read_u16(ops, pos)?;
let prop_str_idx = read_u32(ops, pos + 2)?;
let target_slot_id = read_u16(ops, pos + 6)?;
pos += 8;
let prop_name = strings.get(prop_str_idx)?;
let value = slots.get(src_slot_id).get_property(prop_name);
let previous = slots.replace(target_slot_id, value);
state.journal_write(target_slot_id, previous);
}
}
}
match mode {
WalkMode::Range { .. } => {
if state.pending_tag_close {
push_checked(out, ">", limit)?;
state.pending_tag_close = false;
}
Ok(())
}
WalkMode::UntilIslandEnd { target_island_id } => {
Err(IrError::IslandNotFound(target_island_id))
}
}
}
fn build_island_props(
module: &IrModule,
slots: &SlotData,
slot_ids: &[u16],
) -> serde_json::Map<String, serde_json::Value> {
let mut map = serde_json::Map::new();
for &slot_id in slot_ids {
if let Some(entry) = module.slot_entry(slot_id) {
if let Ok(name) = module.strings.get(entry.name_str_idx) {
if map.contains_key(name) {
#[cfg(feature = "tracing")]
tracing::warn!(
slot_id = slot_id,
name = name,
"Island declares two slots with the same name — keeping the first, \
the later value is not reachable from the props object"
);
continue;
}
let value = slots.get(slot_id).to_json();
map.insert(name.to_string(), value);
}
} else {
#[cfg(feature = "tracing")]
tracing::warn!(
slot_id = slot_id,
"Island references a slot id that is not in the slot table — prop dropped"
);
}
}
map
}
fn bounded_offset(base: usize, len: usize, limit: usize) -> Result<usize, IrError> {
base.checked_add(len)
.filter(|e| *e <= limit)
.ok_or(IrError::InvalidSection)
}
fn is_valid_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':' => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'))
}
fn is_event_handler_attr(name: &str) -> bool {
name.len() >= 2 && name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
}
const URL_ATTRS: [&str; 8] = [
"href",
"src",
"action",
"formaction",
"xlink:href",
"poster",
"background",
"data",
];
const IMAGE_CONTEXT_TAGS: [&str; 13] = [
"img", "image", "video", "audio", "source", "body", "table", "thead", "tbody", "tfoot", "tr",
"td", "th",
];
fn is_url_attr(name: &str) -> bool {
URL_ATTRS.iter().any(|a| name.eq_ignore_ascii_case(a))
}
fn is_dangerous_url(value: &str, tag: &str) -> bool {
let normalized: String = value
.chars()
.filter(|c| {
let n = *c as u32;
!((n <= 0x20) || (0x7F..=0x9F).contains(&n))
})
.collect();
let lower = normalized.to_ascii_lowercase();
if lower.starts_with("javascript") || lower.starts_with("vbscript") {
return true;
}
if lower.starts_with("data:text/html") {
return true;
}
if lower.starts_with("data:image/svg+xml") {
return !IMAGE_CONTEXT_TAGS
.iter()
.any(|t| tag.eq_ignore_ascii_case(t));
}
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AttrValue<'a> {
Bare,
Valued(&'a str),
}
fn emit_attr(
out: &mut String,
tag: &str,
key: &str,
val: AttrValue<'_>,
limit: usize,
) -> Result<(), IrError> {
if !is_valid_name(key) {
#[cfg(feature = "tracing")]
tracing::warn!(attr = key, "Dropping attribute with an unsafe name");
return Ok(());
}
if is_event_handler_attr(key) {
#[cfg(feature = "tracing")]
tracing::warn!(attr = key, "Dropping event-handler attribute from IR");
return Ok(());
}
if let AttrValue::Valued(v) = val {
if is_url_attr(key) && is_dangerous_url(v, tag) {
#[cfg(feature = "tracing")]
tracing::warn!(attr = key, "Dropping URL attribute with a dangerous scheme");
return Ok(());
}
}
push_checked(out, " ", limit)?;
push_checked(out, key, limit)?;
if let AttrValue::Valued(v) = val {
push_checked(out, "=\"", limit)?;
push_escaped_attr(out, v, limit)?;
push_checked(out, "\"", limit)?;
}
Ok(())
}
fn emit_open_tag(
out: &mut String,
tag: &str,
attrs: &[(&str, &str)],
state: &mut WalkState,
limit: usize,
) -> Result<(), IrError> {
if !is_valid_name(tag) {
return Err(IrError::InvalidName(tag.to_string()));
}
push_checked(out, "<", limit)?;
push_checked(out, tag, limit)?;
for (key, val) in attrs {
let value = if val.is_empty() {
AttrValue::Bare
} else {
AttrValue::Valued(val)
};
emit_attr(out, tag, key, value, limit)?;
}
if let Some(island) = state.pending_island.take() {
push_checked(out, " data-forma-island=\"", limit)?;
push_u16(out, island.id, limit)?;
push_checked(out, "\" data-forma-component=\"", limit)?;
push_escaped_attr(out, &island.component_name, limit)?;
push_checked(out, "\" data-forma-status=\"pending\"", limit)?;
let trigger_str = match island.trigger {
IslandTrigger::Load => "load",
IslandTrigger::Visible => "visible",
IslandTrigger::Interaction => "interaction",
IslandTrigger::Idle => "idle",
};
push_checked(out, " data-forma-hydrate=\"", limit)?;
push_checked(out, trigger_str, limit)?;
push_checked(out, "\"", limit)?;
if let Some(ref props_json) = island.inline_props {
push_checked(out, " data-forma-props=\"", limit)?;
push_escaped_attr(out, props_json, limit)?;
push_checked(out, "\"", limit)?;
}
}
if let Some(key) = state.pending_list_key.take() {
push_checked(out, " data-forma-key=\"", limit)?;
push_escaped_attr(out, &key, limit)?;
push_checked(out, "\"", limit)?;
}
state.pending_tag_close = true;
state.pending_tag_name.clear();
state.pending_tag_name.push_str(tag);
Ok(())
}
pub(crate) fn read_u16(data: &[u8], pos: usize) -> Result<u16, IrError> {
if pos + 2 > data.len() {
return Err(IrError::BufferTooShort {
expected: pos + 2,
actual: data.len(),
});
}
Ok(u16::from_le_bytes(data[pos..pos + 2].try_into().unwrap()))
}
pub(crate) fn read_u32(data: &[u8], pos: usize) -> Result<u32, IrError> {
if pos + 4 > data.len() {
return Err(IrError::BufferTooShort {
expected: pos + 4,
actual: data.len(),
});
}
Ok(u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()))
}
pub(crate) fn read_tag_with_attrs<'a>(
ops: &[u8],
pos: usize,
strings: &'a crate::parser::StringTable,
) -> Result<(u32, Vec<(&'a str, &'a str)>, usize), IrError> {
let tag_str_idx = read_u32(ops, pos)?;
let attr_count = read_u16(ops, pos + 4)? as usize;
let mut cursor = pos + 6;
let mut attrs = Vec::with_capacity(attr_count.min(ops.len().saturating_sub(cursor) / 8));
for _ in 0..attr_count {
let key_idx = read_u32(ops, cursor)?;
let val_idx = read_u32(ops, cursor + 4)?;
let key = strings.get(key_idx)?;
let val = strings.get(val_idx)?;
attrs.push((key, val));
cursor += 8;
}
Ok((tag_str_idx, attrs, cursor))
}
const MAX_ESCAPE_EXPANSION: usize = 6;
fn push_checked(out: &mut String, s: &str, limit: usize) -> Result<(), IrError> {
if out.len().saturating_add(s.len()) > limit {
return Err(IrError::OutputLimitExceeded { limit });
}
out.push_str(s);
Ok(())
}
fn push_u16(out: &mut String, value: u16, limit: usize) -> Result<(), IrError> {
let mut buf = [0u8; 5];
let mut remaining = value;
let mut start = buf.len();
loop {
start -= 1;
buf[start] = b'0' + (remaining % 10) as u8;
remaining /= 10;
if remaining == 0 {
break;
}
}
let digits = core::str::from_utf8(&buf[start..]).expect("ASCII digits are valid UTF-8");
push_checked(out, digits, limit)
}
fn push_escaped_html(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
if out
.len()
.saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
<= limit
{
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(ch),
}
}
return Ok(());
}
for ch in text.chars() {
match ch {
'&' => push_checked(out, "&", limit)?,
'<' => push_checked(out, "<", limit)?,
'>' => push_checked(out, ">", limit)?,
_ => {
if out.len() >= limit {
return Err(IrError::OutputLimitExceeded { limit });
}
out.push(ch);
}
}
}
Ok(())
}
fn push_escaped_attr(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
if out
.len()
.saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
<= limit
{
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'"' => out.push_str("""),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(ch),
}
}
return Ok(());
}
for ch in text.chars() {
match ch {
'&' => push_checked(out, "&", limit)?,
'"' => push_checked(out, """, limit)?,
'<' => push_checked(out, "<", limit)?,
'>' => push_checked(out, ">", limit)?,
_ => {
if out.len() >= limit {
return Err(IrError::OutputLimitExceeded { limit });
}
out.push(ch);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::test_helpers::{
build_minimal_ir, encode_close_tag, encode_list, encode_open_tag, encode_preload,
encode_show_if, encode_switch, encode_text, encode_try, encode_void_tag,
};
use crate::slot::{SlotData, SlotValue};
fn encode_dyn_text(slot_id: u16, marker_id: u16) -> Vec<u8> {
let mut buf = Vec::new();
buf.push(0x05); buf.extend_from_slice(&slot_id.to_le_bytes());
buf.extend_from_slice(&marker_id.to_le_bytes());
buf
}
fn encode_island_start(island_id: u16) -> Vec<u8> {
let mut buf = vec![0x0B];
buf.extend_from_slice(&island_id.to_le_bytes());
buf
}
fn encode_island_end(island_id: u16) -> Vec<u8> {
let mut buf = vec![0x0C];
buf.extend_from_slice(&island_id.to_le_bytes());
buf
}
fn encode_comment(str_idx: u32) -> Vec<u8> {
let mut buf = vec![0x10];
buf.extend_from_slice(&str_idx.to_le_bytes());
buf
}
fn walk_static(strings: &[&str], opcodes: &[u8]) -> String {
let data = build_minimal_ir(strings, &[], opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
walk_to_html(&module, &slots).unwrap()
}
fn walk_with_slots(
strings: &[&str],
slot_decls: &[(u16, u32, u8, u8, &[u8])],
opcodes: &[u8],
slots: &SlotData,
) -> String {
let data = build_minimal_ir(strings, slot_decls, opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
walk_to_html(&module, slots).unwrap()
}
fn walk_with_islands(
strings: &[&str],
slot_decls: &[(u16, u32, u8, u8, &[u8])],
island_decls: &[(u16, u8, u8, u32, u32, &[u16])],
opcodes: &[u8],
slots: &SlotData,
) -> String {
let data = build_minimal_ir(strings, slot_decls, opcodes, island_decls);
let module = IrModule::parse(&data).unwrap();
walk_to_html(&module, slots).unwrap()
}
fn encode_list_item_key(key_str_idx: u32) -> Vec<u8> {
let mut buf = vec![0x11]; buf.extend_from_slice(&key_str_idx.to_le_bytes());
buf
}
fn encode_dyn_attr(attr_str_idx: u32, slot_id: u16) -> Vec<u8> {
let mut bytes = vec![0x06]; bytes.extend_from_slice(&attr_str_idx.to_le_bytes());
bytes.extend_from_slice(&slot_id.to_le_bytes());
bytes
}
#[test]
fn walk_static_div() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
opcodes.extend_from_slice(&encode_text(3));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["div", "class", "container", "Hello"], &opcodes);
assert_eq!(html, r#"<div class="container">Hello</div>"#);
}
#[test]
fn walk_void_tag() {
let opcodes = encode_void_tag(0, &[(1, 2)]);
let html = walk_static(&["input", "type", "email"], &opcodes);
assert_eq!(html, r#"<input type="email">"#);
}
#[test]
fn walk_nested_elements() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_text(2));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["div", "span", "Hi"], &opcodes);
assert_eq!(html, "<div><span>Hi</span></div>");
}
#[test]
fn walk_text_escaping() {
let opcodes = encode_text(0);
let html = walk_static(&["<script>alert('xss')</script>"], &opcodes);
assert_eq!(html, "<script>alert('xss')</script>");
}
#[test]
fn walk_attr_escaping() {
let opcodes = encode_open_tag(0, &[(1, 2)]);
let html = walk_static(&["div", "title", "a\"b"], &opcodes);
assert_eq!(html, r#"<div title="a"b">"#);
}
#[test]
fn walk_empty_element() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["div"], &opcodes);
assert_eq!(html, "<div></div>");
}
#[test]
fn walk_dyn_text() {
let opcodes = encode_dyn_text(0, 0);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".to_string()));
let html = walk_with_slots(&["greeting"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t0-->World<!--/f:t0-->");
}
#[test]
fn walk_dyn_text_null_emits_zwsp() {
let opcodes = encode_dyn_text(0, 0);
let slots = SlotData::new(1);
let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
}
#[test]
fn walk_dyn_text_empty_string_emits_zwsp() {
let opcodes = encode_dyn_text(0, 0);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(String::new()));
let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
}
#[test]
fn walk_dyn_text_escaping() {
let opcodes = encode_dyn_text(0, 1);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("<b>bold</b>".to_string()));
let html = walk_with_slots(&["content"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
assert_eq!(html, "<!--f:t1--><b>bold</b><!--/f:t1-->");
}
#[test]
fn walk_island_markers() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_text(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["Hello"],
&[],
&[(0, 0x01, 0x01, 0, 0, &[])],
&opcodes,
&slots,
);
assert_eq!(html, "<!--f:i0-->Hello<!--/f:i0-->");
}
#[test]
fn walk_comment() {
let opcodes = encode_comment(0);
let html = walk_static(&["This is a comment"], &opcodes);
assert_eq!(html, "<!--This is a comment-->");
}
#[test]
fn walk_boolean_attr() {
let opcodes = encode_void_tag(0, &[(1, 2)]);
let html = walk_static(&["input", "disabled", ""], &opcodes);
assert_eq!(html, "<input disabled>");
}
fn walk_dyn_attr_with(value: SlotValue) -> String {
let mut opcodes = encode_void_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let mut slots = SlotData::new(1);
slots.set(0, value);
walk_with_slots(
&["input", "disabled"],
&[(0, 1, 0x02, 0x00, &[])],
&opcodes,
&slots,
)
}
#[test]
fn dyn_attr_bool_matches_the_client_renderer() {
assert_eq!(
walk_dyn_attr_with(SlotValue::Bool(true)),
"<input disabled>",
"true must render the bare attribute"
);
assert_eq!(
walk_dyn_attr_with(SlotValue::Bool(false)),
"<input>",
"false must omit the attribute, not write disabled=\"false\""
);
assert_eq!(
walk_dyn_attr_with(SlotValue::Null),
"<input>",
"null must omit the attribute"
);
}
#[test]
fn dyn_attr_empty_string_is_a_value_not_an_absence() {
assert_eq!(
walk_dyn_attr_with(SlotValue::Text(String::new())),
"<input disabled=\"\">"
);
assert_eq!(
walk_dyn_attr_with(SlotValue::Text("yes".into())),
"<input disabled=\"yes\">"
);
assert_eq!(
walk_dyn_attr_with(SlotValue::Number(0.0)),
"<input disabled=\"0\">"
);
}
#[test]
fn walk_multiple_attrs() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2), (3, 4), (5, 6)]));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(
&["div", "id", "main", "class", "container", "data-x", "42"],
&opcodes,
);
assert_eq!(
html,
r#"<div id="main" class="container" data-x="42"></div>"#
);
}
#[test]
fn walk_show_if_true_branch() {
let then_ops = encode_text(1); let else_ops = encode_text(2); let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(true));
let html = walk_with_slots(
&["visible", "Yes", "No"],
&[(0, 0, 0x02, 0x00, &[])], &opcodes,
&slots,
);
assert!(html.contains("Yes"), "should contain then-branch text");
assert!(!html.contains("No"), "should NOT contain else-branch text");
assert!(html.contains("<!--f:s0-->"), "should have opening marker");
assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
}
#[test]
fn walk_show_if_false_branch() {
let then_ops = encode_text(1);
let else_ops = encode_text(2);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(false));
let html = walk_with_slots(
&["visible", "Yes", "No"],
&[(0, 0, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert!(html.contains("No"), "should contain else-branch text");
assert!(!html.contains("Yes"), "should NOT contain then-branch text");
assert!(html.contains("<!--f:s0-->"), "should have opening marker");
assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
}
#[test]
fn walk_show_if_truthy_text() {
let then_ops = encode_text(1);
let else_ops = encode_text(2);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("hello".to_string()));
let html = walk_with_slots(
&["greeting", "Shown", "Hidden"],
&[(0, 0, 0x01, 0x00, &[])], &opcodes,
&slots,
);
assert!(
html.contains("Shown"),
"truthy text should render then branch"
);
assert!(
!html.contains("Hidden"),
"truthy text should NOT render else branch"
);
}
#[test]
fn walk_show_if_falsy_null() {
let then_ops = encode_text(1);
let else_ops = encode_text(2);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let slots = SlotData::new(1);
let html = walk_with_slots(
&["maybe", "Present", "Missing"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(html.contains("Missing"), "null should render else branch");
assert!(
!html.contains("Present"),
"null should NOT render then branch"
);
}
#[test]
fn walk_show_if_no_else() {
let then_ops = encode_text(1);
let else_ops: Vec<u8> = vec![]; let opcodes = encode_show_if(0, &then_ops, &else_ops);
let slots = SlotData::new(1);
let html = walk_with_slots(
&["flag", "Content"],
&[(0, 0, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:s0--><!--/f:s0-->",
"empty else should produce only markers"
);
}
#[test]
fn walk_show_if_nested() {
let inner_then = encode_text(4);
let inner_else = encode_text(5);
let inner_show_if = encode_show_if(1, &inner_then, &inner_else);
let outer_then = inner_show_if; let outer_else = encode_text(3);
let opcodes = encode_show_if(0, &outer_then, &outer_else);
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Bool(true)); slots.set(1, SlotValue::Bool(false));
let html = walk_with_slots(
&[
"outer",
"inner",
"outer-yes",
"outer-no",
"inner-yes",
"inner-no",
],
&[(0, 0, 0x02, 0x00, &[]), (1, 1, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("inner-no"),
"nested: inner false should render inner else"
);
assert!(
!html.contains("inner-yes"),
"nested: inner true branch should not render"
);
assert!(
!html.contains("outer-no"),
"nested: outer else should not render"
);
assert!(
html.contains("<!--f:s0-->"),
"should have outer opening marker"
);
assert!(
html.contains("<!--/f:s0-->"),
"should have outer closing marker"
);
assert!(
html.contains("<!--f:s1-->"),
"should have inner opening marker"
);
assert!(
html.contains("<!--/f:s1-->"),
"should have inner closing marker"
);
}
#[test]
fn walk_show_if_with_elements() {
let mut then_ops = Vec::new();
then_ops.extend_from_slice(&encode_open_tag(1, &[]));
then_ops.extend_from_slice(&encode_text(2));
then_ops.extend_from_slice(&encode_close_tag(1));
let else_ops = encode_text(3);
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(true));
let html = walk_with_slots(
&["flag", "span", "Hello", "Bye"],
&[(0, 0, 0x02, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:s0--><span>Hello</span><!--/f:s0-->",
"then branch should produce full HTML element"
);
}
#[test]
fn walk_list_renders_items() {
let body = encode_dyn_text(1, 0);
let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::array(vec![
SlotValue::Text("Alice".to_string()),
SlotValue::Text("Bob".to_string()),
]),
);
let html = walk_with_slots(
&["items", "item"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
&slots,
);
assert!(html.contains("Alice"), "should contain first item");
assert!(html.contains("Bob"), "should contain second item");
assert!(
html.starts_with("<!--f:l0-->"),
"should start with list open marker"
);
assert!(
html.ends_with("<!--/f:l0-->"),
"should end with list close marker"
);
}
#[test]
fn walk_list_empty_array() {
let body = encode_dyn_text(1, 0);
let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::array(vec![]));
let html = walk_with_slots(
&["items", "item"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:l0--><!--/f:l0-->",
"empty array should produce only markers"
);
}
#[test]
fn walk_list_with_elements() {
let mut body = Vec::new();
body.extend_from_slice(&encode_open_tag(2, &[]));
body.extend_from_slice(&encode_dyn_text(1, 0));
body.extend_from_slice(&encode_close_tag(2));
let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::array(vec![
SlotValue::Text("A".to_string()),
SlotValue::Text("B".to_string()),
]),
);
let html = walk_with_slots(
&["items", "item", "li"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("<li><!--f:t0-->A<!--/f:t0--></li><li><!--f:t0-->B<!--/f:t0--></li>"),
"should render li elements with content, got: {html}"
);
}
#[test]
fn walk_list_null_slot() {
let body = encode_dyn_text(1, 0);
let opcodes = encode_list(0, 1, &body);
let slots = SlotData::new(2);
let html = walk_with_slots(
&["items", "item"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<!--f:l0--><!--/f:l0-->",
"null slot should produce only markers"
);
}
#[test]
fn walk_list_nested() {
let inner_body = encode_dyn_text(2, 0);
let inner_list = encode_list(1, 2, &inner_body);
let opcodes = encode_list(0, 1, &inner_list);
let mut slots = SlotData::new(3);
slots.set(
0,
SlotValue::array(vec![
SlotValue::array(vec![
SlotValue::Text("a".to_string()),
SlotValue::Text("b".to_string()),
]),
SlotValue::array(vec![SlotValue::Text("c".to_string())]),
]),
);
let html = walk_with_slots(
&["outer", "inner", "item"],
&[
(0, 0, 0x04, 0x00, &[]),
(1, 1, 0x04, 0x00, &[]),
(2, 2, 0x01, 0x00, &[]),
],
&opcodes,
&slots,
);
assert!(html.contains("a"), "should contain nested item 'a'");
assert!(html.contains("b"), "should contain nested item 'b'");
assert!(html.contains("c"), "should contain nested item 'c'");
assert!(
html.starts_with("<!--f:l0-->"),
"should have outer list open marker"
);
assert!(
html.ends_with("<!--/f:l0-->"),
"should have outer list close marker"
);
assert!(
html.contains("<!--f:l1-->"),
"should have inner list open marker"
);
assert!(
html.contains("<!--/f:l1-->"),
"should have inner list close marker"
);
}
#[test]
fn walk_list_depth_exceeded() {
let innermost_body = encode_dyn_text(5, 0);
let level5 = encode_list(4, 5, &innermost_body);
let level4 = encode_list(3, 4, &level5);
let level3 = encode_list(2, 3, &level4);
let level2 = encode_list(1, 2, &level3);
let level1 = encode_list(0, 1, &level2);
let mut slots = SlotData::new(6);
slots.set(
0,
SlotValue::array(vec![SlotValue::array(vec![SlotValue::array(vec![
SlotValue::array(vec![SlotValue::array(vec![SlotValue::Text(
"deep".to_string(),
)])]),
])])]),
);
let data = build_minimal_ir(
&["s0", "s1", "s2", "s3", "s4", "s5"],
&[
(0, 0, 0x04, 0x00, &[]),
(1, 1, 0x04, 0x00, &[]),
(2, 2, 0x04, 0x00, &[]),
(3, 3, 0x04, 0x00, &[]),
(4, 4, 0x04, 0x00, &[]),
(5, 5, 0x01, 0x00, &[]),
],
&level1,
&[],
);
let module = IrModule::parse(&data).unwrap();
let result = walk_to_html(&module, &slots);
assert!(result.is_err(), "should fail with depth exceeded");
match result.unwrap_err() {
IrError::ListDepthExceeded { max: 4 } => {}
other => panic!("expected ListDepthExceeded {{ max: 4 }}, got {other:?}"),
}
}
#[test]
fn walk_switch_matching_case() {
let case_home_body = encode_text(2); let case_about_body = encode_text(4); let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("about".to_string()));
let html = walk_with_slots(
&["page", "home", "Home", "about", "About"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(html.contains("About"), "should contain matching case text");
assert!(
!html.contains("Home"),
"should NOT contain non-matching case text"
);
}
#[test]
fn walk_switch_no_match() {
let case_home_body = encode_text(2);
let case_about_body = encode_text(4);
let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("missing".to_string()));
let html = walk_with_slots(
&["page", "home", "Home", "about", "About"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.is_empty(),
"no match should produce empty output, got: {html}"
);
}
#[test]
fn walk_switch_first_case() {
let case_home_body = encode_text(2);
let case_about_body = encode_text(4);
let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("home".to_string()));
let html = walk_with_slots(
&["page", "home", "Home", "about", "About"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(html.contains("Home"), "should contain first case text");
assert!(
!html.contains("About"),
"should NOT contain second case text"
);
}
#[test]
fn walk_switch_stops_at_the_first_match() {
let opcodes = encode_switch(0, &[(1, &encode_text(2)), (1, &encode_text(3))]);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("home".to_string()));
let html = walk_with_slots(
&["page", "home", "FIRST", "SECOND"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(html, "FIRST");
}
#[test]
fn walk_switch_with_elements() {
let mut active_body = Vec::new();
active_body.extend_from_slice(&encode_open_tag(2, &[]));
active_body.extend_from_slice(&encode_text(3));
active_body.extend_from_slice(&encode_close_tag(2));
let inactive_body = encode_text(5);
let opcodes = encode_switch(0, &[(1, &active_body), (4, &inactive_body)]);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("active".to_string()));
let html = walk_with_slots(
&["page", "active", "span", "Active", "inactive", "Inactive"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html, "<span>Active</span>",
"should produce full HTML element from matching case"
);
}
#[test]
fn walk_try_renders_main() {
let main_ops = encode_text(0);
let fallback_ops = encode_text(1);
let opcodes = encode_try(&main_ops, &fallback_ops);
let html = walk_static(&["Main", "Fallback"], &opcodes);
assert!(html.contains("Main"), "should contain main body text");
assert!(
!html.contains("Fallback"),
"should NOT contain fallback text"
);
}
#[test]
fn walk_preload_font() {
let opcodes = encode_preload(1, 0);
let html = walk_static(&["/_assets/dm-mono.woff2"], &opcodes);
assert!(
html.contains(r#"<link rel="preload" href="/_assets/dm-mono.woff2" as="font" type="font/woff2" crossorigin>"#),
"should produce preload link for font, got: {html}"
);
}
#[test]
fn walk_preload_style() {
let opcodes = encode_preload(2, 0);
let html = walk_static(&["/_assets/styles.css"], &opcodes);
assert!(
html.contains(r#"as="style""#),
"should contain as=\"style\", got: {html}"
);
assert!(
html.contains(r#"href="/_assets/styles.css""#),
"should contain correct href, got: {html}"
);
}
#[test]
fn roundtrip_page_structure() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); opcodes.extend_from_slice(&encode_open_tag(3, &[])); opcodes.extend_from_slice(&encode_void_tag(4, &[(5, 6)])); opcodes.extend_from_slice(&encode_open_tag(7, &[])); opcodes.extend_from_slice(&encode_text(8)); opcodes.extend_from_slice(&encode_close_tag(7)); opcodes.extend_from_slice(&encode_close_tag(3)); opcodes.extend_from_slice(&encode_open_tag(9, &[])); opcodes.extend_from_slice(&encode_open_tag(10, &[(11, 12)])); opcodes.extend_from_slice(&encode_close_tag(10)); opcodes.extend_from_slice(&encode_close_tag(9)); opcodes.extend_from_slice(&encode_close_tag(0));
let ir = build_minimal_ir(
&[
"html", "lang", "en", "head", "meta", "charset", "utf-8", "title", "Test", "body",
"div", "id", "app",
],
&[],
&opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let slots = SlotData::new(0);
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<html lang="en"><head><meta charset="utf-8"><title>Test</title></head><body><div id="app"></div></body></html>"#
);
}
#[test]
fn roundtrip_dynamic_greeting() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); opcodes.extend_from_slice(&encode_dyn_text(0, 0)); opcodes.extend_from_slice(&encode_close_tag(0));
let ir = build_minimal_ir(
&["div", "class", "greeting", "content"],
&[(0, 3, 0x01, 0x00, &[])], &opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("Hello, World".into()));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<div class="greeting"><!--f:t0-->Hello, World<!--/f:t0--></div>"#
);
}
#[test]
fn roundtrip_conditional_content() {
let mut then_ops = Vec::new();
then_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
then_ops.extend_from_slice(&encode_text(4));
then_ops.extend_from_slice(&encode_close_tag(1));
let mut else_ops = Vec::new();
else_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
else_ops.extend_from_slice(&encode_text(5));
else_ops.extend_from_slice(&encode_close_tag(1));
let opcodes = encode_show_if(0, &then_ops, &else_ops);
let ir = build_minimal_ir(
&["auth", "span", "class", "badge", "Logged In", "Guest"],
&[(0, 0, 0x02, 0x00, &[])], &opcodes,
&[],
);
{
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(true));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<!--f:s0--><span class="badge">Logged In</span><!--/f:s0-->"#
);
}
{
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Bool(false));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<!--f:s0--><span class="badge">Guest</span><!--/f:s0-->"#
);
}
}
#[test]
fn roundtrip_list_rendering() {
let mut body = Vec::new();
body.extend_from_slice(&encode_open_tag(2, &[])); body.extend_from_slice(&encode_dyn_text(1, 0)); body.extend_from_slice(&encode_close_tag(2));
let opcodes = encode_list(0, 1, &body);
let ir = build_minimal_ir(
&["items", "item", "li"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::array(vec![
SlotValue::Text("Alice".into()),
SlotValue::Text("Bob".into()),
SlotValue::Text("Charlie".into()),
]),
);
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
"<!--f:l0-->\
<li><!--f:t0-->Alice<!--/f:t0--></li>\
<li><!--f:t0-->Bob<!--/f:t0--></li>\
<li><!--f:t0-->Charlie<!--/f:t0--></li>\
<!--/f:l0-->"
);
}
#[test]
fn roundtrip_switch_view() {
let mut home_body = Vec::new();
home_body.extend_from_slice(&encode_open_tag(2, &[]));
home_body.extend_from_slice(&encode_text(3));
home_body.extend_from_slice(&encode_close_tag(2));
let mut about_body = Vec::new();
about_body.extend_from_slice(&encode_open_tag(2, &[]));
about_body.extend_from_slice(&encode_text(5));
about_body.extend_from_slice(&encode_close_tag(2));
let opcodes = encode_switch(0, &[(1, &home_body), (4, &about_body)]);
let ir = build_minimal_ir(
&["page", "home", "h1", "Home", "about", "About"],
&[(0, 0, 0x01, 0x00, &[])], &opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("about".into()));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(html, "<h1>About</h1>");
}
#[test]
fn roundtrip_nested_structure() {
let mut then_ops = Vec::new();
then_ops.extend_from_slice(&encode_open_tag(4, &[]));
then_ops.extend_from_slice(&encode_text(5));
then_ops.extend_from_slice(&encode_close_tag(4));
let show_if_ops = encode_show_if(0, &then_ops, &[]);
let mut list_body = Vec::new();
list_body.extend_from_slice(&encode_open_tag(9, &[]));
list_body.extend_from_slice(&encode_dyn_text(2, 0));
list_body.extend_from_slice(&encode_close_tag(9));
let list_ops = encode_list(1, 2, &list_body);
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)])); opcodes.extend_from_slice(&show_if_ops); opcodes.extend_from_slice(&encode_open_tag(8, &[])); opcodes.extend_from_slice(&list_ops); opcodes.extend_from_slice(&encode_close_tag(8)); opcodes.extend_from_slice(&encode_close_tag(0));
let ir = build_minimal_ir(
&[
"nav", "class", "main-nav", "auth", "span", "Welcome", "items", "item", "ul", "li",
],
&[
(0, 3, 0x02, 0x00, &[]), (1, 6, 0x04, 0x00, &[]), (2, 7, 0x01, 0x00, &[]), ],
&opcodes,
&[],
);
let module = IrModule::parse(&ir).unwrap();
let mut slots = SlotData::new(3);
slots.set(0, SlotValue::Bool(true));
slots.set(
1,
SlotValue::array(vec![
SlotValue::Text("Home".into()),
SlotValue::Text("About".into()),
]),
);
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(
html,
r#"<nav class="main-nav"><!--f:s0--><span>Welcome</span><!--/f:s0--><ul><!--f:l1--><li><!--f:t0-->Home<!--/f:t0--></li><li><!--f:t0-->About<!--/f:t0--></li><!--/f:l1--></ul></nav>"#
);
}
#[test]
fn walk_island_attrs_on_open_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_text(1));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "Hello", "AuthForm"],
&[],
&[(0, 0x01, 0x01, 2, 0, &[])],
&opcodes,
&slots,
);
assert_eq!(
html,
r#"<!--f:i0--><div data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load">Hello</div><!--/f:i0-->"#
);
}
#[test]
fn walk_island_attrs_with_existing_attrs() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "class", "form", "AuthForm"],
&[],
&[(0, 0x01, 0x01, 3, 0, &[])],
&opcodes,
&slots,
);
assert_eq!(
html,
r#"<!--f:i0--><div class="form" data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load"></div><!--/f:i0-->"#
);
}
#[test]
fn walk_island_attrs_on_void_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_void_tag(0, &[]));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["input", "Widget"],
&[],
&[(0, 0x01, 0x01, 1, 0, &[])],
&opcodes,
&slots,
);
assert_eq!(
html,
r#"<!--f:i0--><input data-forma-island="0" data-forma-component="Widget" data-forma-status="pending" data-forma-hydrate="load"><!--/f:i0-->"#
);
}
#[test]
fn walk_inline_props_attribute() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("Hello".to_string()));
slots.set(1, SlotValue::Number(42.0));
let html = walk_with_islands(
&["div", "MyComponent", "title", "count"],
&[
(0, 2, 0x01, 0x00, &[]), (1, 3, 0x03, 0x00, &[]), ],
&[(0, 0x01, 0x01, 1, 0, &[0, 1])],
&opcodes,
&slots,
);
assert!(
html.contains("data-forma-props="),
"should contain data-forma-props, got: {html}"
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island id, got: {html}"
);
assert!(
html.contains(r#"data-forma-component="MyComponent""#),
"should contain component name, got: {html}"
);
let marker = "data-forma-props=\"";
let props_start = html.find(marker).unwrap() + marker.len();
let props_end = html[props_start..].find('"').unwrap() + props_start;
let raw_attr = &html[props_start..props_end];
let decoded = raw_attr
.replace(""", "\"")
.replace("&", "&")
.replace("<", "<")
.replace(">", ">");
let props_json: serde_json::Value = serde_json::from_str(&decoded).unwrap();
assert_eq!(props_json["title"], "Hello");
assert_eq!(props_json["count"], 42);
}
#[test]
fn walk_list_item_key_on_first_open_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_list_item_key(1));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_text(2));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["li", "user-123", "item"], &opcodes);
assert_eq!(html, r#"<li data-forma-key="user-123">item</li>"#);
}
#[test]
fn walk_list_item_key_on_void_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_list_item_key(1));
opcodes.extend_from_slice(&encode_void_tag(0, &[]));
let html = walk_static(&["hr", "key-42"], &opcodes);
assert_eq!(html, r#"<hr data-forma-key="key-42">"#);
}
#[test]
fn walk_list_item_key_with_attrs() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_list_item_key(3));
opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
opcodes.extend_from_slice(&encode_text(4));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["li", "class", "item", "k1", "content"], &opcodes);
assert_eq!(html, r#"<li class="item" data-forma-key="k1">content</li>"#);
}
#[test]
fn walk_script_tag_props_mode() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("Click me".to_string()));
let html = walk_with_islands(
&["div", "Counter", "label"],
&[(0, 2, 0x01, 0x00, &[])], &[(0, 0x01, 0x02, 1, 0, &[0])],
&opcodes,
&slots,
);
assert!(
!html.contains("data-forma-props"),
"ScriptTag mode should not emit inline props, got: {html}"
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island id"
);
assert!(
html.contains(r#"<script id="__forma_islands" type="application/json">"#),
"should contain script tag, got: {html}"
);
assert!(html.contains("</script>"), "should close script tag");
let script_start =
html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
let script_end = html[script_start..].find("</script>").unwrap() + script_start;
let json: serde_json::Value =
serde_json::from_str(&html[script_start..script_end]).unwrap();
assert_eq!(json["0"]["label"], "Click me");
}
#[test]
fn walk_deferred_props_mode() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("ignored".to_string()));
let html = walk_with_islands(
&["div", "LazyWidget", "data"],
&[(0, 2, 0x01, 0x00, &[])],
&[(0, 0x01, 0x03, 1, 0, &[0])],
&opcodes,
&slots,
);
assert!(
!html.contains("data-forma-props"),
"Deferred mode should not emit inline props, got: {html}"
);
assert!(
!html.contains("__forma_islands"),
"Deferred mode should not emit script tag, got: {html}"
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island id"
);
assert!(
html.contains(r#"data-forma-component="LazyWidget""#),
"should contain component name"
);
}
#[test]
fn walk_island_no_entry_in_table() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(5));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_text(1));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(5));
let data = build_minimal_ir(&["div", "Hello"], &[], &opcodes, &[]);
assert_eq!(
IrModule::parse(&data).unwrap_err(),
IrError::UnknownIslandId(5)
);
}
#[test]
fn walk_nested_island_start_before_a_root_element_is_an_error() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_island_start(1));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(1));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let data = build_minimal_ir(
&["div", "Outer", "Inner"],
&[],
&opcodes,
&[(0, 0x01, 0x01, 1, 0, &[]), (1, 0x01, 0x01, 2, 3, &[])],
);
let module = IrModule::parse(&data).unwrap();
assert_eq!(
walk_to_html(&module, &slots).unwrap_err(),
IrError::NestedIslandRoot { outer: 0, inner: 1 }
);
}
#[test]
fn walk_island_nested_inside_another_islands_root_is_fine() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_island_start(1));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(1));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let inner_offset = (encode_island_start(0).len() + encode_open_tag(0, &[]).len()) as u32;
let html = walk_with_islands(
&["div", "Outer", "Inner"],
&[],
&[
(0, 0x01, 0x01, 1, 0, &[]),
(1, 0x01, 0x01, 2, inner_offset, &[]),
],
&opcodes,
&slots,
);
assert!(html.contains(r#"data-forma-component="Outer""#));
assert!(html.contains(r#"data-forma-component="Inner""#));
}
#[test]
fn walk_inline_props_empty_slots() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "Simple"],
&[],
&[(0, 0x01, 0x01, 1, 0, &[])], &opcodes,
&slots,
);
assert!(
!html.contains("data-forma-props"),
"empty slot_ids should not emit props, got: {html}"
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island id"
);
}
fn walk_island_with_prop_payload(payload: &str) -> String {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(payload.to_string()));
walk_with_islands(
&["div", "Big", "blob"],
&[(0, 2, 0x01, 0x00, &[])],
&[(0, 0x01, 0x01, 1, 0, &[0])],
&opcodes,
&slots,
)
}
#[test]
fn island_props_keep_the_first_slot_for_a_duplicated_name() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("first".into()));
slots.set(1, SlotValue::Text("second".into()));
let html = walk_with_islands(
&["div", "Comp", "dup"],
&[(0, 2, 0x01, 0x00, &[]), (1, 2, 0x01, 0x00, &[])],
&[(0, 0x01, 0x01, 1, 0, &[0, 1])],
&opcodes,
&slots,
);
assert!(
html.contains(r#"data-forma-props="{"dup":"first"}""#),
"expected the first slot to win, got: {html}"
);
}
#[test]
fn inline_props_under_the_ceiling_stay_inline() {
let html = walk_island_with_prop_payload("small");
assert!(html.contains(r#"data-forma-props="{"blob":"small"}""#));
assert!(!html.contains("__forma_islands"));
}
#[test]
fn inline_props_over_the_ceiling_spill_to_the_shared_block() {
let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
let html = walk_island_with_prop_payload(&payload);
assert!(
!html.contains("data-forma-props"),
"over-size props must not stay in the attribute"
);
assert!(
html.contains(r#"<script id="__forma_islands" type="application/json">"#),
"over-size props must land in the shared block, got: {}",
&html[..html.len().min(200)]
);
assert!(html.contains(&payload), "the payload itself must survive");
}
#[test]
fn island_fragment_keeps_over_size_props_inline() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(payload));
let data = build_minimal_ir(
&["div", "Big", "blob"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[(0, 0x01, 0x01, 1, 0, &[0])],
);
let module = IrModule::parse(&data).unwrap();
let fragment = walk_island(&module, &slots, 0).unwrap();
assert!(fragment.contains("data-forma-props"));
assert!(!fragment.contains("__forma_islands"));
}
#[test]
fn walk_island_and_list_key_combined() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_list_item_key(2));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["div", "Card", "item-1"],
&[],
&[(0, 0x01, 0x01, 1, 0, &[])],
&opcodes,
&slots,
);
assert!(
html.contains(r#"data-forma-island="0""#),
"should contain island attrs"
);
assert!(
html.contains(r#"data-forma-key="item-1""#),
"should contain list key attr, got: {html}"
);
}
#[test]
fn walk_multiple_islands_script_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let island1_offset = opcodes.len() as u32;
opcodes.extend_from_slice(&encode_island_start(1));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(1));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("hello".to_string()));
slots.set(1, SlotValue::Bool(true));
let html = walk_with_islands(
&["div", "span", "CompA", "CompB", "msg", "flag"],
&[
(0, 4, 0x01, 0x00, &[]), (1, 5, 0x02, 0x00, &[]), ],
&[
(0, 0x01, 0x02, 2, 0, &[0]), (1, 0x01, 0x02, 3, island1_offset, &[1]), ],
&opcodes,
&slots,
);
let script_start =
html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
let script_end = html[script_start..].find("</script>").unwrap() + script_start;
let json: serde_json::Value =
serde_json::from_str(&html[script_start..script_end]).unwrap();
assert_eq!(json["0"]["msg"], "hello");
assert_eq!(json["1"]["flag"], true);
}
#[test]
fn walk_list_item_key_consumed_once() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_list_item_key(2));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_text(3));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_close_tag(0));
let html = walk_static(&["li", "span", "abc", "text"], &opcodes);
assert_eq!(html, r#"<li data-forma-key="abc"><span>text</span></li>"#);
}
#[test]
fn slot_value_to_json_all_types() {
assert_eq!(SlotValue::Null.to_json(), serde_json::Value::Null);
assert_eq!(
SlotValue::Text("hello".to_string()).to_json(),
serde_json::Value::String("hello".to_string())
);
assert_eq!(
SlotValue::Bool(true).to_json(),
serde_json::Value::Bool(true)
);
assert_eq!(
SlotValue::Bool(false).to_json(),
serde_json::Value::Bool(false)
);
assert_eq!(SlotValue::Number(42.0).to_json(), serde_json::json!(42));
assert_eq!(SlotValue::Number(3.15).to_json(), serde_json::json!(3.15));
assert_eq!(
SlotValue::array(vec![
SlotValue::Text("a".to_string()),
SlotValue::Number(1.0)
])
.to_json(),
serde_json::json!(["a", 1])
);
assert_eq!(
SlotValue::object(vec![(
"key".to_string(),
SlotValue::Text("val".to_string())
),])
.to_json(),
serde_json::json!({"key": "val"})
);
}
#[test]
fn dyn_attr_emits_attribute_with_value() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
opcodes.extend_from_slice(&encode_close_tag(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("password".to_string()));
let html = walk_with_slots(
&["input", "type", "input_type"],
&[(0, 2, 0x01, 0x01, b"password")], &opcodes,
&slots,
);
assert_eq!(html, r#"<input type="password"></input>"#);
}
#[test]
fn dyn_attr_skips_when_slot_null() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
opcodes.extend_from_slice(&encode_close_tag(0));
let slots = SlotData::new(1);
let html = walk_with_slots(
&["div", "class", "cls"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(html, "<div></div>");
}
#[test]
fn dyn_attr_multiple_on_same_element() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
opcodes.extend_from_slice(&encode_dyn_attr(2, 1));
opcodes.extend_from_slice(&encode_close_tag(0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("email".to_string()));
slots.set(1, SlotValue::Text("form-input".to_string()));
let html = walk_with_slots(
&["input", "type", "class", "slot_type", "slot_class"],
&[(0, 3, 0x01, 0x01, b"text"), (1, 4, 0x01, 0x01, b"")],
&opcodes,
&slots,
);
assert_eq!(html, r#"<input type="email" class="form-input"></input>"#);
}
#[test]
fn dyn_attr_on_void_tag() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_void_tag(0, &[]));
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("checkbox".to_string()));
let html = walk_with_slots(
&["input", "type", "slot_type"],
&[(0, 2, 0x01, 0x01, b"text")],
&opcodes,
&slots,
);
assert_eq!(html, r#"<input type="checkbox">"#);
}
#[test]
fn walk_island_basic() {
let pre_island = encode_open_tag(1, &[]); let island_start = encode_island_start(0);
let open_p = encode_open_tag(2, &[]); let text_hello = encode_text(3);
let close_p = encode_close_tag(2);
let island_end = encode_island_end(0);
let close_div = encode_close_tag(1);
let byte_offset = pre_island.len() as u32;
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&pre_island);
opcodes.extend_from_slice(&island_start);
opcodes.extend_from_slice(&open_p);
opcodes.extend_from_slice(&text_hello);
opcodes.extend_from_slice(&close_p);
opcodes.extend_from_slice(&island_end);
opcodes.extend_from_slice(&close_div);
let data = build_minimal_ir(
&["MyIsland", "div", "p", "hello"],
&[],
&opcodes,
&[(0, 0x01, 0x01, 0, byte_offset, &[])], );
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
let html = walk_island(&module, &slots, 0).unwrap();
assert_eq!(
html,
"<p data-forma-island=\"0\" data-forma-component=\"MyIsland\" \
data-forma-status=\"pending\" data-forma-hydrate=\"load\">hello</p>"
);
assert!(!html.contains("<!--f:i0-->"));
assert!(!html.contains("<!--/f:i0-->"));
}
#[test]
fn walk_island_attrs_match_walk_to_html() {
let island_start = encode_island_start(0);
let open_div = encode_open_tag(1, &[]);
let dyn_text = encode_dyn_text(0, 0);
let close_div = encode_close_tag(1);
let island_end = encode_island_end(0);
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&island_start);
opcodes.extend_from_slice(&open_div);
opcodes.extend_from_slice(&dyn_text);
opcodes.extend_from_slice(&close_div);
opcodes.extend_from_slice(&island_end);
let data = build_minimal_ir(
&["IslandComp", "div", "greeting"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[(0, 0x02, 0x01, 0, 0, &[0])], );
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".to_string()));
let page = walk_to_html(&module, &slots).unwrap();
let fragment = walk_island(&module, &slots, 0).unwrap();
for attr in [
"data-forma-island=\"0\"",
"data-forma-component=\"IslandComp\"",
"data-forma-status=\"pending\"",
"data-forma-hydrate=\"visible\"",
"data-forma-props=",
] {
assert!(page.contains(attr), "page missing {attr}: {page}");
assert!(
fragment.contains(attr),
"fragment missing {attr}: {fragment}"
);
}
}
#[test]
fn walk_island_script_tag_mode_inlines_props_in_fragment() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(0));
let data = build_minimal_ir(
&["IslandComp", "div", "greeting"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[(0, 0x01, 0x02, 0, 0, &[0])], );
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".to_string()));
let fragment = walk_island(&module, &slots, 0).unwrap();
assert!(
fragment.contains("data-forma-props="),
"ScriptTag island fragment must inline its props: {fragment}"
);
assert!(
!fragment.contains("__forma_islands"),
"fragment must not emit a second islands block: {fragment}"
);
}
#[test]
fn walk_island_nested_script_tag_island_keeps_its_props() {
let mut opcodes = encode_island_start(0);
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
let inner_offset = opcodes.len() as u32;
opcodes.extend_from_slice(&encode_island_start(1));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(1));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(0));
let data = build_minimal_ir(
&["Outer", "div", "greeting", "Inner"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[
(0, 0x01, 0x01, 0, 0, &[]),
(1, 0x01, 0x02, 3, inner_offset, &[0]), ],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".into()));
let fragment = walk_island(&module, &slots, 0).unwrap();
assert!(
fragment.contains("data-forma-component=\"Inner\""),
"nested island must render: {fragment}"
);
assert!(
fragment.contains("data-forma-props="),
"nested ScriptTag island must keep its props inline: {fragment}"
);
assert!(!fragment.contains("__forma_islands"), "{fragment}");
}
#[test]
fn walk_island_not_found() {
let opcodes = encode_text(0);
let data = build_minimal_ir(&["Hello"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
let result = walk_island(&module, &slots, 5);
assert!(result.is_err());
match result.unwrap_err() {
IrError::IslandNotFound(5) => {} other => panic!("expected IslandNotFound(5), got {other:?}"),
}
}
#[test]
fn walk_island_with_dyn_text() {
let island_start = encode_island_start(0);
let open_div = encode_open_tag(1, &[]); let dyn_text = encode_dyn_text(0, 0); let close_div = encode_close_tag(1);
let island_end = encode_island_end(0);
let byte_offset = 0u32;
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&island_start);
opcodes.extend_from_slice(&open_div);
opcodes.extend_from_slice(&dyn_text);
opcodes.extend_from_slice(&close_div);
opcodes.extend_from_slice(&island_end);
let data = build_minimal_ir(
&["IslandComp", "div", "greeting"],
&[(0, 2, 0x01, 0x00, &[])], &opcodes,
&[(0, 0x01, 0x01, 0, byte_offset, &[0])], );
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("World".to_string()));
let html = walk_island(&module, &slots, 0).unwrap();
assert_eq!(
html,
"<div data-forma-island=\"0\" data-forma-component=\"IslandComp\" \
data-forma-status=\"pending\" data-forma-hydrate=\"load\" \
data-forma-props=\"{"greeting":"World"}\">\
<!--f:t0-->World<!--/f:t0--></div>"
);
}
#[test]
fn walk_island_byte_offset_out_of_bounds() {
let opcodes = encode_text(0);
let data = build_minimal_ir(
&["BadIsland", "hello"],
&[],
&opcodes,
&[(0, 0x01, 0x01, 0, 9999, &[])], );
let err = IrModule::parse(&data).unwrap_err();
assert_eq!(
err,
IrError::IslandOffsetMismatch {
island_id: 0,
byte_offset: 9999
}
);
}
fn encode_prop(src_slot_id: u16, prop_str_idx: u32, target_slot_id: u16) -> Vec<u8> {
let mut buf = vec![0x12]; buf.extend_from_slice(&src_slot_id.to_le_bytes());
buf.extend_from_slice(&prop_str_idx.to_le_bytes());
buf.extend_from_slice(&target_slot_id.to_le_bytes());
buf
}
#[test]
fn walk_prop_in_list_body() {
let mut body = Vec::new();
body.extend_from_slice(&encode_prop(1, 2, 2)); body.extend_from_slice(&encode_dyn_text(2, 0)); let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(3);
slots.set(
0,
SlotValue::array(vec![
SlotValue::object(vec![(
"name".to_string(),
SlotValue::Text("Alice".to_string()),
)]),
SlotValue::object(vec![(
"name".to_string(),
SlotValue::Text("Bob".to_string()),
)]),
]),
);
let html = walk_with_slots(
&["items", "item", "name"],
&[
(0, 0, 0x04, 0x00, &[]), (1, 1, 0x05, 0x00, &[]), (2, 2, 0x01, 0x00, &[]), ],
&opcodes,
&slots,
);
assert!(html.contains("Alice"), "should contain Alice, got: {html}");
assert!(html.contains("Bob"), "should contain Bob, got: {html}");
}
#[test]
fn walk_prop_multiple_properties() {
let mut body = Vec::new();
body.extend_from_slice(&encode_prop(1, 2, 2)); body.extend_from_slice(&encode_prop(1, 3, 3)); body.extend_from_slice(&encode_open_tag(4, &[])); body.extend_from_slice(&encode_open_tag(5, &[])); body.extend_from_slice(&encode_dyn_text(2, 0)); body.extend_from_slice(&encode_close_tag(5)); body.extend_from_slice(&encode_open_tag(5, &[])); body.extend_from_slice(&encode_dyn_text(3, 1)); body.extend_from_slice(&encode_close_tag(5)); body.extend_from_slice(&encode_close_tag(4)); let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(4);
slots.set(
0,
SlotValue::array(vec![
SlotValue::object(vec![
("name".to_string(), SlotValue::Text("Alice".to_string())),
(
"email".to_string(),
SlotValue::Text("alice@test.com".to_string()),
),
]),
SlotValue::object(vec![
("name".to_string(), SlotValue::Text("Bob".to_string())),
(
"email".to_string(),
SlotValue::Text("bob@test.com".to_string()),
),
]),
]),
);
let html = walk_with_slots(
&["rows", "row", "name", "email", "tr", "td"],
&[
(0, 0, 0x04, 0x00, &[]),
(1, 1, 0x05, 0x00, &[]),
(2, 2, 0x01, 0x00, &[]),
(3, 3, 0x01, 0x00, &[]),
],
&opcodes,
&slots,
);
assert!(
html.contains("<td><!--f:t0-->Alice<!--/f:t0--></td>"),
"should contain Alice td, got: {html}"
);
assert!(
html.contains("<td><!--f:t1-->alice@test.com<!--/f:t1--></td>"),
"should contain alice email, got: {html}"
);
assert!(
html.contains("<td><!--f:t0-->Bob<!--/f:t0--></td>"),
"should contain Bob td, got: {html}"
);
assert!(
html.contains("<td><!--f:t1-->bob@test.com<!--/f:t1--></td>"),
"should contain bob email, got: {html}"
);
}
#[test]
fn walk_prop_on_non_object() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_prop(0, 1, 1));
opcodes.extend_from_slice(&encode_dyn_text(1, 0));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::Text("hello".to_string()));
let html = walk_with_slots(
&["src", "name"],
&[(0, 0, 0x01, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
"non-object PROP should produce empty text, got: {html}"
);
}
#[test]
fn walk_prop_missing_property() {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_prop(0, 2, 1));
opcodes.extend_from_slice(&encode_dyn_text(1, 0));
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::object(vec![(
"name".to_string(),
SlotValue::Text("Alice".to_string()),
)]),
);
let html = walk_with_slots(
&["src", "target", "missing"],
&[(0, 0, 0x05, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
"missing property should produce empty text, got: {html}"
);
}
#[test]
fn walk_nested_show_if_within_limit() {
let inner = encode_text(5);
let level4 = encode_show_if(4, &inner, &[]);
let level3 = encode_show_if(3, &level4, &[]);
let level2 = encode_show_if(2, &level3, &[]);
let level1 = encode_show_if(1, &level2, &[]);
let level0 = encode_show_if(0, &level1, &[]);
let mut slots = SlotData::new(5);
for i in 0..5 {
slots.set(i, SlotValue::Bool(true));
}
let html = walk_with_slots(
&["s0", "s1", "s2", "s3", "s4", "deep"],
&[
(0, 0, 0x02, 0x00, &[]),
(1, 1, 0x02, 0x00, &[]),
(2, 2, 0x02, 0x00, &[]),
(3, 3, 0x02, 0x00, &[]),
(4, 4, 0x02, 0x00, &[]),
],
&level0,
&slots,
);
assert!(
html.contains("deep"),
"5 levels of nesting should succeed, got: {html}"
);
}
fn script_tag_props_html(payload: &str) -> (String, String) {
let mut opcodes = Vec::new();
opcodes.extend_from_slice(&encode_island_start(0));
opcodes.extend_from_slice(&encode_open_tag(0, &[]));
opcodes.extend_from_slice(&encode_close_tag(0));
opcodes.extend_from_slice(&encode_island_end(0));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(payload.to_string()));
let html = walk_with_islands(
&["div", "Comp", "payload"],
&[(0, 2, 0x01, 0x00, &[])],
&[(0, 0x01, 0x02, 1, 0, &[0])],
&opcodes,
&slots,
);
const OPEN: &str = "type=\"application/json\">";
let start = html.find(OPEN).expect("islands script block") + OPEN.len();
let end = html[start..].find("</script>").expect("script close") + start;
let body = html[start..end].to_string();
(html, body)
}
#[test]
fn walk_script_tag_props_body_has_no_raw_angle_brackets() {
let (_html, body) = script_tag_props_html("</script>alert(1)");
assert!(
!body.contains('<') && !body.contains('>'),
"islands block must contain no raw angle brackets, got: {body}"
);
assert!(body.contains("\\u003c/script\\u003e"), "got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["0"]["payload"], "</script>alert(1)");
}
#[test]
fn walk_script_tag_props_escapes_comment_script_breakout() {
let payload = "<!--<script>PAYLOAD";
let (html, body) = script_tag_props_html(payload);
assert!(
!html.contains("<!--<script"),
"must not emit the script-data-double-escape trigger, got: {html}"
);
assert!(!body.contains('<') && !body.contains('>'), "got: {body}");
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(parsed["0"]["payload"], payload);
}
#[test]
#[ignore = "requires admin/dist IR files — run in monorepo or after `npm run build`"]
fn walk_real_benchmark_ir() {
let dist_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../admin/dist");
let ir_path = std::fs::read_dir(&dist_dir)
.expect("admin/dist must exist")
.filter_map(|e| e.ok())
.find(|e| {
let name = e.file_name();
let s = name.to_string_lossy();
s.starts_with("platform-benchmark.") && s.ends_with(".ir")
})
.map(|e| e.path())
.expect("platform-benchmark.*.ir must exist in admin/dist");
let data = std::fs::read(&ir_path).expect("benchmark IR file must exist");
let module = IrModule::parse(&data).expect("IR must parse");
let slots = SlotData::new_from_defaults(&module.slots);
let html = walk_to_html(&module, &slots).expect("walk must succeed");
assert!(html.contains("<!--f:i0-->"), "missing island 0 start");
assert!(html.contains("<!--/f:i0-->"), "missing island 0 end");
assert!(
html.contains("data-forma-island=\"0\""),
"missing island 0 attr"
);
assert!(
html.contains("Search"),
"FilterBar must contain 'Search' label text in SSR"
);
assert!(
html.contains("filter-group"),
"FilterBar must contain filter-group divs in SSR"
);
assert!(
html.contains("Sort by"),
"FilterBar must contain 'Sort by' label text in SSR"
);
assert!(
html.contains("Performance"),
"PerfPanel must contain 'Performance' heading in SSR"
);
assert!(
html.contains("benchmark-data-table"),
"BenchmarkDataTable root class must exist"
);
let start = html
.find("<!--f:i0-->")
.expect("FilterBar island start marker");
let end = html
.find("<!--/f:i0-->")
.expect("FilterBar island end marker");
assert!(end > start, "island end marker must follow its start");
}
#[test]
fn walk_output_limit_trips_inside_one_opcode() {
let big = "&".repeat(4096); let attrs: Vec<(u32, u32)> = (0..200).map(|_| (1u32, 2u32)).collect();
let opcodes = encode_open_tag(0, &attrs);
let data = build_minimal_ir(&["div", "data-x", &big], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
let limits = WalkLimits {
max_output_bytes: 64 * 1024,
..WalkLimits::default()
};
let err = walk_to_html_with_limits(&module, &slots, limits).unwrap_err();
assert_eq!(err, IrError::OutputLimitExceeded { limit: 64 * 1024 });
let ok = WalkLimits {
max_output_bytes: 64 * 1024 * 1024,
..WalkLimits::default()
};
assert!(walk_to_html_with_limits(&module, &slots, ok).is_ok());
}
#[test]
fn walk_output_limit_trips_on_a_single_dyn_text_value() {
let opcodes = encode_dyn_text(0, 0);
let data = build_minimal_ir(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("x".repeat(100_000)));
let limits = WalkLimits {
max_output_bytes: 4096,
..WalkLimits::default()
};
assert!(matches!(
walk_to_html_with_limits(&module, &slots, limits),
Err(IrError::OutputLimitExceeded { .. })
));
}
#[test]
fn walk_list_budget_exceeded() {
let body = encode_text(2);
let inner = encode_list(0, 1, &body);
let opcodes = encode_list(0, 1, &inner);
let data = build_minimal_ir(
&["items", "item", "x"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::array((0..300).map(|_| SlotValue::Text("a".into())).collect()),
);
let err = walk_to_html(&module, &slots).unwrap_err();
assert_eq!(
err,
IrError::ListBudgetExceeded {
max: DEFAULT_MAX_LIST_ITERATIONS
}
);
assert_ne!(
err,
IrError::ListDepthExceeded {
max: MAX_LIST_DEPTH
}
);
}
fn patch_u32(buf: &mut [u8], at: usize, value: u32) {
buf[at..at + 4].copy_from_slice(&value.to_le_bytes());
}
#[test]
fn parse_rejects_show_if_then_len_past_the_stream() {
let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
patch_u32(&mut opcodes, 3, 0xFFFF_FF00); let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
assert!(matches!(
IrModule::parse(&data),
Err(IrError::MalformedControlFlow { .. })
));
}
#[test]
fn parse_rejects_show_if_marker_byte_that_is_not_show_else() {
let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
patch_u32(&mut opcodes, 3, 4); let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
assert!(matches!(
IrModule::parse(&data),
Err(IrError::MalformedControlFlow { .. })
));
}
#[test]
fn parse_rejects_list_body_len_past_the_stream() {
let mut opcodes = encode_list(0, 1, &encode_text(2));
patch_u32(&mut opcodes, 5, 0xFFFF_FF00);
let data = build_minimal_ir(
&["items", "item", "x"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&[],
);
assert!(matches!(
IrModule::parse(&data),
Err(IrError::MalformedControlFlow { .. })
));
}
#[test]
fn parse_rejects_switch_case_body_len_past_the_stream() {
let mut opcodes = encode_switch(0, &[(1, &encode_text(2)), (3, &encode_text(2))]);
patch_u32(&mut opcodes, 9, 0xFFFF_FF00);
let data = build_minimal_ir(
&["mode", "a", "x", "b"],
&[(0, 0, 0x01, 0x00, &[])],
&opcodes,
&[],
);
assert!(matches!(
IrModule::parse(&data),
Err(IrError::MalformedControlFlow { .. })
));
}
#[test]
fn parse_rejects_fallback_skip_past_the_stream() {
let mut opcodes = encode_try(&encode_text(0), &encode_text(0));
patch_u32(&mut opcodes, 1, 0xFFFF_FF00);
let data = build_minimal_ir(&["hi"], &[], &opcodes, &[]);
assert!(matches!(
IrModule::parse(&data),
Err(IrError::MalformedControlFlow { .. })
));
}
#[test]
fn parse_accepts_zero_length_else_branch_known_residual() {
let opcodes = encode_show_if(0, &encode_text(1), &[]);
let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
assert!(IrModule::parse(&data).is_ok());
}
#[test]
fn walk_stray_fallback_in_a_nested_range_is_rejected_at_load() {
let then_ops = vec![0x0E]; let mut main_ops = encode_show_if(0, &then_ops, &[]);
main_ops.extend_from_slice(&encode_text(1));
let opcodes = encode_try(&main_ops, &encode_text(2));
let data = build_minimal_ir(
&["flag", "main", "fb"],
&[(0, 0, 0x02, 0x00, &[])],
&opcodes,
&[],
);
assert!(matches!(
IrModule::parse(&data).unwrap_err(),
IrError::MalformedControlFlow { .. }
));
}
#[test]
fn walk_try_without_a_fallback_is_rejected_at_load() {
let mut opcodes = vec![0x0D]; opcodes.extend_from_slice(&4u32.to_le_bytes()); opcodes.extend_from_slice(&encode_text(0));
let data = build_minimal_ir(&["main"], &[], &opcodes, &[]);
assert!(matches!(
IrModule::parse(&data).unwrap_err(),
IrError::MalformedControlFlow { .. }
));
}
#[test]
fn walk_island_attrs_survive_an_empty_show_if_before_the_root() {
let mut opcodes = encode_island_start(0);
opcodes.extend_from_slice(&encode_show_if(0, &[], &[]));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
opcodes.extend_from_slice(&encode_island_end(0));
let slots = SlotData::new(1);
let html = walk_with_islands(
&["Comp", "div", "flag"],
&[(0, 2, 0x02, 0x00, &[])],
&[(0, 0x01, 0x01, 0, 0, &[])],
&opcodes,
&slots,
);
assert!(
html.contains("<div data-forma-island=\"0\""),
"island root must keep its attributes, got: {html}"
);
}
#[test]
fn walk_list_key_survives_an_empty_show_if_before_the_item_element() {
let mut body = encode_list_item_key(3);
body.extend_from_slice(&encode_show_if(1, &[], &[]));
body.extend_from_slice(&encode_open_tag(2, &[]));
body.extend_from_slice(&encode_close_tag(2));
let opcodes = encode_list(0, 1, &body);
let mut slots = SlotData::new(2);
slots.set(
0,
SlotValue::array(vec![
SlotValue::Text("a".into()),
SlotValue::Text("b".into()),
]),
);
let html = walk_with_slots(
&["items", "item", "li", "k"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert_eq!(
html.matches("data-forma-key=\"k\"").count(),
2,
"every item element must keep its key, got: {html}"
);
}
#[test]
fn walk_island_attrs_do_not_leak_past_the_island_end() {
let mut opcodes = encode_island_start(0);
opcodes.extend_from_slice(&encode_island_end(0));
opcodes.extend_from_slice(&encode_open_tag(1, &[]));
opcodes.extend_from_slice(&encode_close_tag(1));
let slots = SlotData::new(0);
let html = walk_with_islands(
&["Comp", "div"],
&[],
&[(0, 0x01, 0x01, 0, 0, &[])],
&opcodes,
&slots,
);
assert!(
!html.contains("data-forma-island"),
"island attributes must not leak onto an element outside the island, got: {html}"
);
}
#[test]
fn walk_list_key_does_not_leak_past_the_list() {
let body = encode_list_item_key(2); let mut opcodes = encode_list(0, 1, &body);
opcodes.extend_from_slice(&encode_open_tag(3, &[]));
opcodes.extend_from_slice(&encode_close_tag(3));
let mut slots = SlotData::new(2);
slots.set(0, SlotValue::array(vec![SlotValue::Text("a".into())]));
let html = walk_with_slots(
&["items", "item", "k", "div"],
&[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
&opcodes,
&slots,
);
assert!(
!html.contains("data-forma-key"),
"list key must not leak onto an element after the list, got: {html}"
);
}
#[test]
fn walk_rejects_hostile_tag_name() {
let opcodes = encode_open_tag(0, &[]);
let data = build_minimal_ir(&["div onload=\"alert(1)\""], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
assert!(matches!(
walk_to_html(&module, &slots),
Err(IrError::InvalidName(_))
));
}
#[test]
fn walk_rejects_hostile_close_tag_name() {
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_close_tag(1));
let data = build_minimal_ir(&["div", "div><script"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
assert!(matches!(
walk_to_html(&module, &slots),
Err(IrError::InvalidName(_))
));
}
#[test]
fn walk_drops_hostile_attribute_names() {
let opcodes = encode_open_tag(0, &[(1, 2), (3, 2)]);
let data = build_minimal_ir(&["div", "x\"><script", "v", "ok-attr"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(html, "<div ok-attr=\"v\">");
}
#[test]
fn walk_drops_hostile_boolean_attribute_name() {
let opcodes = encode_open_tag(0, &[(1, 2)]);
let data = build_minimal_ir(&["div", "y\"><script", ""], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let slots = SlotData::new(0);
assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
}
#[test]
fn walk_drops_hostile_dyn_attr_name() {
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let data = build_minimal_ir(
&["div", "z\"><script", "val"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("x".into()));
assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
}
#[test]
fn walk_drops_javascript_url_in_dyn_attr() {
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let data = build_minimal_ir(
&["a", "href", "url"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
for payload in [
"javascript:alert(1)",
"JaVaScRiPt:alert(1)",
"java\tscript:alert(1)",
" javascript:alert(1)",
"vbscript:msgbox(1)",
"data:text/html,<script>alert(1)</script>",
] {
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(payload.to_string()));
let html = walk_to_html(&module, &slots).unwrap();
assert_eq!(html, "<a>", "payload {payload:?} was not dropped");
}
}
#[test]
fn walk_keeps_safe_urls_and_data_images() {
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let data = build_minimal_ir(
&["img", "src", "url"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
for payload in [
"/about",
"https://example.com/x?a=1",
"data:image/png;base64,AAAA",
"data:image/svg+xml,%3Csvg%3E%3C/svg%3E",
] {
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text(payload.to_string()));
let html = walk_to_html(&module, &slots).unwrap();
assert!(html.contains("src="), "payload {payload:?} was dropped");
}
}
#[test]
fn walk_blocks_svg_data_url_outside_image_context() {
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let data = build_minimal_ir(
&["a", "href", "url"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(
0,
SlotValue::Text("data:image/svg+xml,%3Csvg onload=alert(1)%3E".into()),
);
assert_eq!(walk_to_html(&module, &slots).unwrap(), "<a>");
}
#[test]
fn walk_drops_event_handler_attributes() {
let opcodes = encode_open_tag(0, &[(1, 2)]);
let data = build_minimal_ir(&["div", "onmouseover", "alert(1)"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
assert_eq!(
walk_to_html(&module, &SlotData::new(0)).unwrap(),
"<div>",
"static on* attribute must be dropped"
);
let mut opcodes = encode_open_tag(0, &[]);
opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
let data = build_minimal_ir(
&["div", "ONCLICK", "h"],
&[(0, 2, 0x01, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Text("alert(1)".into()));
assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
}
#[test]
fn walk_drops_javascript_url_in_static_attr() {
let opcodes = encode_open_tag(0, &[(1, 2)]);
let data = build_minimal_ir(&["a", "href", "javascript:alert(1)"], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
assert_eq!(walk_to_html(&module, &SlotData::new(0)).unwrap(), "<a>");
}
#[test]
fn walk_comment_cannot_self_close() {
for text in ["><img src=x onerror=alert(1)>", "-><img src=x>"] {
let opcodes = encode_comment(0);
let data = build_minimal_ir(&[text], &[], &opcodes, &[]);
let module = IrModule::parse(&data).unwrap();
let html = walk_to_html(&module, &SlotData::new(0)).unwrap();
assert!(
html.starts_with("<!-- "),
"comment must not start with a closing sequence, got: {html}"
);
}
}
#[test]
fn walk_switch_matches_on_js_number_text() {
let opcodes = encode_switch(0, &[(1, &encode_text(2)), (3, &encode_text(4))]);
let data = build_minimal_ir(
&["n", "1e+21", "big", "1000", "small"],
&[(0, 0, 0x03, 0x00, &[])],
&opcodes,
&[],
);
let module = IrModule::parse(&data).unwrap();
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Number(1e21));
assert!(walk_to_html(&module, &slots).unwrap().contains("big"));
let mut slots = SlotData::new(1);
slots.set(0, SlotValue::Number(1000.0));
assert!(walk_to_html(&module, &slots).unwrap().contains("small"));
}
}