1#[cfg(feature = "tracing")]
8use crate::format::SlotSource;
9use crate::format::{IrError, IslandTrigger, Opcode, PropsMode};
10use crate::parser::IrModule;
11use crate::slot::{SlotData, SlotValue};
12
13struct IslandPending {
19 id: u16,
20 component_name: String,
21 inline_props: Option<String>,
22 trigger: IslandTrigger,
23}
24
25pub const INLINE_PROPS_MAX_BYTES: usize = 1024;
47
48pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
57
58pub const DEFAULT_MAX_LIST_ITERATIONS: usize = 50_000;
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct WalkLimits {
70 pub max_output_bytes: usize,
72 pub max_list_iterations: usize,
74}
75
76impl Default for WalkLimits {
77 fn default() -> Self {
78 WalkLimits {
79 max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
80 max_list_iterations: DEFAULT_MAX_LIST_ITERATIONS,
81 }
82 }
83}
84
85struct WalkState {
90 list_depth: u8,
92 pending_island: Option<IslandPending>,
94 pending_list_key: Option<String>,
96 script_tag_props: std::collections::BTreeMap<u16, serde_json::Value>,
98 pending_tag_close: bool,
101 pending_tag_name: String,
104 max_output_bytes: usize,
108 list_iterations: usize,
111 max_list_iterations: usize,
113 slot_journal: Vec<(u16, SlotValue)>,
127 force_inline_props: bool,
132}
133
134impl WalkState {
135 fn new(limits: WalkLimits) -> Self {
136 WalkState {
137 list_depth: 0,
138 pending_island: None,
139 pending_list_key: None,
140 script_tag_props: std::collections::BTreeMap::new(),
141 pending_tag_close: false,
142 pending_tag_name: String::new(),
143 max_output_bytes: limits.max_output_bytes,
144 list_iterations: 0,
145 max_list_iterations: limits.max_list_iterations,
146 slot_journal: Vec::new(),
147 force_inline_props: false,
148 }
149 }
150
151 fn journal_write(&mut self, slot_id: u16, previous: SlotValue) {
157 if self.list_depth > 0 {
158 self.slot_journal.push((slot_id, previous));
159 }
160 }
161
162 fn unwind_slot_journal(&mut self, slots: &mut SlotData, mark: usize) {
164 while self.slot_journal.len() > mark {
165 if let Some((slot_id, previous)) = self.slot_journal.pop() {
167 slots.set(slot_id, previous);
168 }
169 }
170 }
171}
172
173#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
184fn check_slot_source(module: &IrModule, slot_id: u16, value: &SlotValue) {
185 #[cfg(feature = "tracing")]
189 if let Some(entry) = module.slot_entry(slot_id) {
190 if entry.source == SlotSource::Server && matches!(value, SlotValue::Null) {
191 tracing::warn!(
192 slot_id = slot_id,
193 "Server-sourced slot has no value at render time — handler may have a bug"
194 );
195 }
196 }
197}
198
199pub fn walk_to_html(module: &IrModule, slots: &SlotData) -> Result<String, IrError> {
209 walk_to_html_with_limits(module, slots, WalkLimits::default())
210}
211
212pub fn walk_to_html_with_limits(
219 module: &IrModule,
220 slots: &SlotData,
221 limits: WalkLimits,
222) -> Result<String, IrError> {
223 let ops = &module.opcodes;
224 let len = ops.len();
225 let mut out = String::with_capacity(len.saturating_mul(4).min(1 << 20));
231 let mut state = WalkState::new(limits);
232 let mut slots_mut = slots.clone();
233 walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)?;
234
235 if !state.script_tag_props.is_empty() {
237 let json =
238 serde_json::to_string(&state.script_tag_props).unwrap_or_else(|_| "{}".to_string());
239 push_checked(
240 &mut out,
241 "<script id=\"__forma_islands\" type=\"application/json\">",
242 state.max_output_bytes,
243 )?;
244 push_json_script_body(&mut out, &json, state.max_output_bytes)?;
245 push_checked(&mut out, "</script>", state.max_output_bytes)?;
246 }
247
248 Ok(out)
249}
250
251fn push_json_script_body(out: &mut String, json: &str, limit: usize) -> Result<(), IrError> {
272 for ch in json.chars() {
273 match ch {
274 '<' => push_checked(out, "\\u003c", limit)?,
275 '>' => push_checked(out, "\\u003e", limit)?,
276 '\u{2028}' => push_checked(out, "\\u2028", limit)?,
277 '\u{2029}' => push_checked(out, "\\u2029", limit)?,
278 _ => {
279 if out.len() >= limit {
280 return Err(IrError::OutputLimitExceeded { limit });
281 }
282 out.push(ch);
283 }
284 }
285 }
286 Ok(())
287}
288
289pub fn walk_island(module: &IrModule, slots: &SlotData, island_id: u16) -> Result<String, IrError> {
313 walk_island_with_limits(module, slots, island_id, WalkLimits::default())
314}
315
316pub fn walk_island_with_limits(
318 module: &IrModule,
319 slots: &SlotData,
320 island_id: u16,
321 limits: WalkLimits,
322) -> Result<String, IrError> {
323 let entry = module
325 .island_entry(island_id)
326 .ok_or(IrError::IslandNotFound(island_id))?;
327
328 let byte_offset = entry.byte_offset as usize;
329 let ops = &module.opcodes;
330
331 if byte_offset >= ops.len() {
334 return Err(IrError::BufferTooShort {
335 expected: byte_offset.saturating_add(1),
336 actual: ops.len(),
337 });
338 }
339
340 let opcode = Opcode::from_byte(ops[byte_offset])?;
342 if opcode != Opcode::IslandStart {
343 return Err(IrError::InvalidOpcode(ops[byte_offset]));
344 }
345
346 let content_start = byte_offset.saturating_add(3);
348 if content_start > ops.len() {
349 return Err(IrError::BufferTooShort {
350 expected: content_start,
351 actual: ops.len(),
352 });
353 }
354
355 let mut out = String::with_capacity(256);
359 let mut state = WalkState::new(limits);
360 state.force_inline_props = true;
363 let mut slots_mut = slots.clone();
364
365 let component_name = module.strings.get(entry.name_str_idx)?.to_string();
366 let inline_props = match entry.props_mode {
367 PropsMode::Inline | PropsMode::ScriptTag => {
370 if entry.slot_ids.is_empty() {
371 None
372 } else {
373 let props = build_island_props(module, &slots_mut, &entry.slot_ids);
374 Some(serde_json::to_string(&props).unwrap_or_else(|_| "{}".to_string()))
375 }
376 }
377 PropsMode::Deferred => None,
378 };
379 state.pending_island = Some(IslandPending {
380 id: island_id,
381 component_name,
382 inline_props,
383 trigger: entry.trigger,
384 });
385
386 walk_range_until_island_end(
388 module,
389 &mut slots_mut,
390 ops,
391 content_start,
392 island_id,
393 &mut out,
394 &mut state,
395 0,
396 )?;
397
398 Ok(out)
399}
400
401const MAX_LIST_DEPTH: u8 = 4;
408
409const MAX_RECURSION_DEPTH: usize = 64;
412
413enum WalkMode {
415 Range { end: usize },
419 UntilIslandEnd { target_island_id: u16 },
422}
423
424#[allow(clippy::too_many_arguments)]
431fn walk_range(
432 module: &IrModule,
433 slots: &mut SlotData,
434 ops: &[u8],
435 start: usize,
436 end: usize,
437 out: &mut String,
438 state: &mut WalkState,
439 depth: usize,
440) -> Result<(), IrError> {
441 walk_range_impl(
442 module,
443 slots,
444 ops,
445 start,
446 WalkMode::Range { end },
447 out,
448 state,
449 depth,
450 )
451}
452
453#[allow(clippy::too_many_arguments)]
460fn walk_range_until_island_end(
461 module: &IrModule,
462 slots: &mut SlotData,
463 ops: &[u8],
464 start: usize,
465 target_island_id: u16,
466 out: &mut String,
467 state: &mut WalkState,
468 depth: usize,
469) -> Result<(), IrError> {
470 walk_range_impl(
471 module,
472 slots,
473 ops,
474 start,
475 WalkMode::UntilIslandEnd { target_island_id },
476 out,
477 state,
478 depth,
479 )
480}
481
482#[allow(clippy::too_many_arguments)]
497fn walk_range_impl(
498 module: &IrModule,
499 slots: &mut SlotData,
500 ops: &[u8],
501 start: usize,
502 mode: WalkMode,
503 out: &mut String,
504 state: &mut WalkState,
505 depth: usize,
506) -> Result<(), IrError> {
507 if depth > MAX_RECURSION_DEPTH {
508 return Err(IrError::RecursionLimitExceeded);
509 }
510
511 let strings = &module.strings;
512 let mut pos = start;
513 let limit = state.max_output_bytes;
514 let mut fallback_stack: Vec<usize> = Vec::new();
522
523 let loop_bound = match mode {
527 WalkMode::Range { end } => end,
528 WalkMode::UntilIslandEnd { .. } => usize::MAX, };
530
531 let region_limit = match mode {
535 WalkMode::Range { end } => end.min(ops.len()),
536 WalkMode::UntilIslandEnd { .. } => ops.len(),
537 };
538
539 while pos < loop_bound {
540 if out.len() > limit {
543 return Err(IrError::OutputLimitExceeded { limit });
544 }
545
546 if pos >= ops.len() {
549 match mode {
550 WalkMode::Range { .. } => {
551 return Err(IrError::BufferTooShort {
552 expected: pos + 1,
553 actual: ops.len(),
554 });
555 }
556 WalkMode::UntilIslandEnd { .. } => break, }
558 }
559
560 let opcode = Opcode::from_byte(ops[pos])?;
561 pos += 1; if state.pending_tag_close && opcode != Opcode::DynAttr {
566 push_checked(out, ">", limit)?;
567 state.pending_tag_close = false;
568 }
569
570 match opcode {
571 Opcode::OpenTag | Opcode::VoidTag => {
573 let (tag_str_idx, attrs, new_pos) = read_tag_with_attrs(ops, pos, strings)?;
574 let tag = strings.get(tag_str_idx)?;
575 emit_open_tag(out, tag, &attrs, state, limit)?;
576 pos = new_pos;
577 }
578
579 Opcode::CloseTag => {
580 let str_idx = read_u32(ops, pos)?;
581 pos += 4;
582 let tag = strings.get(str_idx)?;
583 if !is_valid_name(tag) {
586 return Err(IrError::InvalidName(tag.to_string()));
587 }
588 push_checked(out, "</", limit)?;
589 push_checked(out, tag, limit)?;
590 push_checked(out, ">", limit)?;
591 }
592
593 Opcode::Text => {
594 let str_idx = read_u32(ops, pos)?;
595 pos += 4;
596 let text = strings.get(str_idx)?;
597 push_escaped_html(out, text, limit)?;
598 }
599
600 Opcode::DynText => {
601 let slot_id = read_u16(ops, pos)?;
603 pos += 2;
604 let marker_id = read_u16(ops, pos)?;
605 pos += 2;
606
607 check_slot_source(module, slot_id, slots.get(slot_id));
608 let value = slots.get(slot_id).to_text();
609
610 push_checked(out, "<!--f:t", limit)?;
611 push_u16(out, marker_id, limit)?;
612 push_checked(out, "-->", limit)?;
613 if value.is_empty() {
614 push_checked(out, "\u{200B}", limit)?;
620 } else {
621 push_escaped_html(out, &value, limit)?;
622 }
623 push_checked(out, "<!--/f:t", limit)?;
624 push_u16(out, marker_id, limit)?;
625 push_checked(out, "-->", limit)?;
626 }
627
628 Opcode::DynAttr => {
629 let attr_str_idx = read_u32(ops, pos)?;
631 let slot_id = read_u16(ops, pos + 4)?;
632 pos += 6;
633 check_slot_source(module, slot_id, slots.get(slot_id));
634
635 if state.pending_tag_close {
636 let attr_name = strings.get(attr_str_idx)?;
637 let text;
640 let value = match slots.get(slot_id) {
641 SlotValue::Bool(false) | SlotValue::Null => None,
647 SlotValue::Bool(true) => Some(AttrValue::Bare),
648 other => {
649 text = other.to_text();
650 Some(AttrValue::Valued(text.as_str()))
655 }
656 };
657 if let Some(value) = value {
658 let tag = std::mem::take(&mut state.pending_tag_name);
662 emit_attr(out, &tag, attr_name, value, limit)?;
663 state.pending_tag_name = tag;
664 }
665 }
666 }
668
669 Opcode::IslandStart => {
670 let island_id = read_u16(ops, pos)?;
672 pos += 2;
673
674 if let Some(outer) = &state.pending_island {
681 return Err(IrError::NestedIslandRoot {
682 outer: outer.id,
683 inner: island_id,
684 });
685 }
686
687 push_checked(out, "<!--f:i", limit)?;
688 push_u16(out, island_id, limit)?;
689 push_checked(out, "-->", limit)?;
690
691 if let Some(entry) = module.island_entry(island_id) {
697 let component_name = strings.get(entry.name_str_idx)?.to_string();
698
699 let inline_props = match entry.props_mode {
701 PropsMode::Inline => {
702 if entry.slot_ids.is_empty() {
703 None
704 } else {
705 let props = build_island_props(module, slots, &entry.slot_ids);
706 let json = serde_json::to_string(&props)
707 .unwrap_or_else(|_| "{}".to_string());
708 if json.len() > INLINE_PROPS_MAX_BYTES && !state.force_inline_props
709 {
710 state
716 .script_tag_props
717 .insert(island_id, serde_json::Value::Object(props));
718 None
719 } else {
720 Some(json)
721 }
722 }
723 }
724 PropsMode::ScriptTag if state.force_inline_props => {
725 if entry.slot_ids.is_empty() {
729 None
730 } else {
731 let props = build_island_props(module, slots, &entry.slot_ids);
732 Some(
733 serde_json::to_string(&props)
734 .unwrap_or_else(|_| "{}".to_string()),
735 )
736 }
737 }
738 PropsMode::ScriptTag => {
739 if !entry.slot_ids.is_empty() {
741 let props = build_island_props(module, slots, &entry.slot_ids);
742 state
743 .script_tag_props
744 .insert(island_id, serde_json::Value::Object(props));
745 }
746 None
747 }
748 PropsMode::Deferred => None,
749 };
750
751 state.pending_island = Some(IslandPending {
752 id: island_id,
753 component_name,
754 inline_props,
755 trigger: entry.trigger,
756 });
757 } else {
758 #[cfg(feature = "tracing")]
759 tracing::warn!(
760 island_id = island_id,
761 "ISLAND_START names an island that is not in the island table — \
762 markers emitted but no data-forma-* attributes, so it will not hydrate \
763 (module was not validated)"
764 );
765 }
766 }
767
768 Opcode::IslandEnd => {
769 let island_id = read_u16(ops, pos)?;
771 pos += 2;
772
773 if state.pending_island.take().is_some() {
779 #[cfg(feature = "tracing")]
780 tracing::warn!(
781 island_id = island_id,
782 "ISLAND_START was not followed by an element — island attributes dropped, \
783 this island will not hydrate (compiler should emit a shell element)"
784 );
785 }
786
787 if let WalkMode::UntilIslandEnd { target_island_id } = mode {
789 if island_id == target_island_id {
790 if state.pending_tag_close {
792 push_checked(out, ">", limit)?;
793 state.pending_tag_close = false;
794 }
795 return Ok(());
796 }
797 }
798
799 push_checked(out, "<!--/f:i", limit)?;
802 push_u16(out, island_id, limit)?;
803 push_checked(out, "-->", limit)?;
804 }
805
806 Opcode::Comment => {
807 let str_idx = read_u32(ops, pos)?;
809 pos += 4;
810 let text = strings.get(str_idx)?;
811 push_checked(out, "<!--", limit)?;
812 if text.starts_with('>') || text.starts_with("->") {
818 push_checked(out, " ", limit)?;
819 }
820 push_checked(out, &text.replace("--", "--"), limit)?;
821 push_checked(out, "-->", limit)?;
822 }
823
824 Opcode::ShowIf => {
826 let slot_id = read_u16(ops, pos)?;
828 let then_len = read_u32(ops, pos + 2)? as usize;
829 let else_len = read_u32(ops, pos + 6)? as usize;
830 pos += 10;
831
832 check_slot_source(module, slot_id, slots.get(slot_id));
833 let condition = slots.get(slot_id).as_bool();
834 let then_start = pos;
835 let then_end = bounded_offset(pos, then_len, region_limit)?;
839 if then_end >= region_limit || ops[then_end] != Opcode::ShowElse as u8 {
843 return Err(IrError::InvalidSection);
844 }
845 let else_start = then_end + 1; let else_end = bounded_offset(else_start, else_len, region_limit)?;
847
848 push_checked(out, "<!--f:s", limit)?;
850 push_u16(out, slot_id, limit)?;
851 push_checked(out, "-->", limit)?;
852
853 if condition {
854 walk_range(
855 module,
856 slots,
857 ops,
858 then_start,
859 then_end,
860 out,
861 state,
862 depth + 1,
863 )?;
864 } else {
865 walk_range(
866 module,
867 slots,
868 ops,
869 else_start,
870 else_end,
871 out,
872 state,
873 depth + 1,
874 )?;
875 }
876
877 push_checked(out, "<!--/f:s", limit)?;
878 push_u16(out, slot_id, limit)?;
879 push_checked(out, "-->", limit)?;
880
881 pos = else_end;
883 }
884
885 Opcode::ShowElse => {
886 }
890
891 Opcode::Switch => {
902 let slot_id = read_u16(ops, pos)?;
903 let case_count = read_u16(ops, pos + 2)? as usize;
904 pos += 4;
905
906 let mut cases =
910 Vec::with_capacity(case_count.min(ops.len().saturating_sub(pos) / 8));
911 for _ in 0..case_count {
912 let val_str_idx = read_u32(ops, pos)?;
913 let body_len = read_u32(ops, pos + 4)? as usize;
914 cases.push((val_str_idx, body_len));
915 pos += 8;
916 }
917
918 check_slot_source(module, slot_id, slots.get(slot_id));
920 let slot_text = slots.get(slot_id).to_text();
921 let mut body_pos = pos;
922 let mut matched = false;
926
927 for (val_str_idx, body_len) in &cases {
928 let case_end = bounded_offset(body_pos, *body_len, region_limit)?;
932 let case_val = strings.get(*val_str_idx)?;
933 if !matched && case_val == slot_text {
934 matched = true;
935 walk_range(
936 module,
937 slots,
938 ops,
939 body_pos,
940 case_end,
941 out,
942 state,
943 depth + 1,
944 )?;
945 }
946 body_pos = case_end;
947 }
948
949 pos = body_pos; }
951
952 Opcode::List => {
953 let slot_id = read_u16(ops, pos)?;
955 let item_slot_id = read_u16(ops, pos + 2)?;
956 let body_len = read_u32(ops, pos + 4)? as usize;
957 pos += 8;
958
959 check_slot_source(module, slot_id, slots.get(slot_id));
960
961 let body_start = pos;
962 let body_end = bounded_offset(pos, body_len, region_limit)?;
963
964 let list_marker_id = slot_id;
966 push_checked(out, "<!--f:l", limit)?;
967 push_u16(out, list_marker_id, limit)?;
968 push_checked(out, "-->", limit)?;
969
970 let items = match slots.get(slot_id) {
976 SlotValue::Array(a) => Some(a.clone()),
977 _ => None,
978 };
979
980 if let Some(items) = items.filter(|a| !a.is_empty()) {
981 state.list_depth += 1;
982 if state.list_depth > MAX_LIST_DEPTH {
983 return Err(IrError::ListDepthExceeded {
984 max: MAX_LIST_DEPTH,
985 });
986 }
987 for item in items.iter() {
988 state.list_iterations += 1;
992 if state.list_iterations > state.max_list_iterations {
993 return Err(IrError::ListBudgetExceeded {
994 max: state.max_list_iterations,
995 });
996 }
997 let mark = state.slot_journal.len();
1002 let previous = slots.replace(item_slot_id, item.clone());
1003 state.slot_journal.push((item_slot_id, previous));
1004
1005 let walked = walk_range(
1006 module,
1007 slots,
1008 ops,
1009 body_start,
1010 body_end,
1011 out,
1012 state,
1013 depth + 1,
1014 );
1015 state.unwind_slot_journal(slots, mark);
1019 walked?;
1020
1021 state.pending_list_key = None;
1026 }
1027 state.list_depth -= 1;
1028 }
1029 state.pending_list_key = None;
1030
1031 push_checked(out, "<!--/f:l", limit)?;
1032 push_u16(out, list_marker_id, limit)?;
1033 push_checked(out, "-->", limit)?;
1034
1035 pos = body_end;
1036 }
1037
1038 Opcode::TryStart => {
1039 let fallback_len = read_u32(ops, pos)? as usize;
1041 pos += 4;
1042 fallback_stack.push(fallback_len);
1045 }
1046
1047 Opcode::Fallback => {
1048 if let Some(skip) = fallback_stack.pop() {
1051 pos = bounded_offset(pos, skip, region_limit)?;
1052 }
1053 }
1054
1055 Opcode::Preload => {
1056 if pos >= ops.len() {
1057 return Err(IrError::BufferTooShort {
1058 expected: pos + 1,
1059 actual: ops.len(),
1060 });
1061 }
1062 let resource_type = ops[pos];
1063 let url_str_idx = read_u32(ops, pos + 1)?;
1064 let url = strings.get(url_str_idx)?;
1065 pos += 5;
1066
1067 let (as_val, type_attr) = match resource_type {
1068 1 => ("font", " type=\"font/woff2\" crossorigin"),
1069 2 => ("style", ""),
1070 3 => ("script", ""),
1071 4 => ("image", ""),
1072 _ => ("fetch", ""),
1073 };
1074 push_checked(out, "<link rel=\"preload\" href=\"", limit)?;
1082 push_escaped_attr(out, url, limit)?;
1083 push_checked(out, "\" as=\"", limit)?;
1084 push_checked(out, as_val, limit)?;
1085 push_checked(out, "\"", limit)?;
1086 push_checked(out, type_attr, limit)?;
1087 push_checked(out, ">\n", limit)?;
1088 }
1089
1090 Opcode::ListItemKey => {
1091 let key_str_idx = read_u32(ops, pos)?;
1093 pos += 4;
1094 let key_val = strings.get(key_str_idx)?;
1095 state.pending_list_key = Some(key_val.to_string());
1096 }
1097
1098 Opcode::Prop => {
1099 let src_slot_id = read_u16(ops, pos)?;
1101 let prop_str_idx = read_u32(ops, pos + 2)?;
1102 let target_slot_id = read_u16(ops, pos + 6)?;
1103 pos += 8;
1104
1105 let prop_name = strings.get(prop_str_idx)?;
1106 let value = slots.get(src_slot_id).get_property(prop_name);
1107 let previous = slots.replace(target_slot_id, value);
1112 state.journal_write(target_slot_id, previous);
1113 }
1114 }
1115 }
1116
1117 match mode {
1119 WalkMode::Range { .. } => {
1120 if state.pending_tag_close {
1130 push_checked(out, ">", limit)?;
1131 state.pending_tag_close = false;
1132 }
1133 Ok(())
1134 }
1135 WalkMode::UntilIslandEnd { target_island_id } => {
1136 Err(IrError::IslandNotFound(target_island_id))
1139 }
1140 }
1141}
1142
1143fn build_island_props(
1159 module: &IrModule,
1160 slots: &SlotData,
1161 slot_ids: &[u16],
1162) -> serde_json::Map<String, serde_json::Value> {
1163 let mut map = serde_json::Map::new();
1164 for &slot_id in slot_ids {
1165 if let Some(entry) = module.slot_entry(slot_id) {
1167 if let Ok(name) = module.strings.get(entry.name_str_idx) {
1168 if map.contains_key(name) {
1169 #[cfg(feature = "tracing")]
1170 tracing::warn!(
1171 slot_id = slot_id,
1172 name = name,
1173 "Island declares two slots with the same name — keeping the first, \
1174 the later value is not reachable from the props object"
1175 );
1176 continue;
1177 }
1178 let value = slots.get(slot_id).to_json();
1179 map.insert(name.to_string(), value);
1180 }
1181 } else {
1182 #[cfg(feature = "tracing")]
1187 tracing::warn!(
1188 slot_id = slot_id,
1189 "Island references a slot id that is not in the slot table — prop dropped"
1190 );
1191 }
1192 }
1193 map
1194}
1195
1196fn bounded_offset(base: usize, len: usize, limit: usize) -> Result<usize, IrError> {
1206 base.checked_add(len)
1207 .filter(|e| *e <= limit)
1208 .ok_or(IrError::InvalidSection)
1209}
1210
1211fn is_valid_name(name: &str) -> bool {
1225 let mut chars = name.chars();
1226 match chars.next() {
1227 Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':' => {}
1228 _ => return false,
1229 }
1230 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'))
1231}
1232
1233fn is_event_handler_attr(name: &str) -> bool {
1235 name.len() >= 2 && name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
1236}
1237
1238const URL_ATTRS: [&str; 8] = [
1246 "href",
1247 "src",
1248 "action",
1249 "formaction",
1250 "xlink:href",
1251 "poster",
1252 "background",
1253 "data",
1254];
1255
1256const IMAGE_CONTEXT_TAGS: [&str; 13] = [
1260 "img", "image", "video", "audio", "source", "body", "table", "thead", "tbody", "tfoot", "tr",
1261 "td", "th",
1262];
1263
1264fn is_url_attr(name: &str) -> bool {
1266 URL_ATTRS.iter().any(|a| name.eq_ignore_ascii_case(a))
1267}
1268
1269fn is_dangerous_url(value: &str, tag: &str) -> bool {
1278 let normalized: String = value
1279 .chars()
1280 .filter(|c| {
1281 let n = *c as u32;
1282 !((n <= 0x20) || (0x7F..=0x9F).contains(&n))
1283 })
1284 .collect();
1285 let lower = normalized.to_ascii_lowercase();
1286
1287 if lower.starts_with("javascript") || lower.starts_with("vbscript") {
1288 return true;
1289 }
1290 if lower.starts_with("data:text/html") {
1291 return true;
1292 }
1293 if lower.starts_with("data:image/svg+xml") {
1298 return !IMAGE_CONTEXT_TAGS
1299 .iter()
1300 .any(|t| tag.eq_ignore_ascii_case(t));
1301 }
1302 false
1303}
1304
1305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1312enum AttrValue<'a> {
1313 Bare,
1315 Valued(&'a str),
1317}
1318
1319fn emit_attr(
1326 out: &mut String,
1327 tag: &str,
1328 key: &str,
1329 val: AttrValue<'_>,
1330 limit: usize,
1331) -> Result<(), IrError> {
1332 if !is_valid_name(key) {
1333 #[cfg(feature = "tracing")]
1337 tracing::warn!(attr = key, "Dropping attribute with an unsafe name");
1338 return Ok(());
1339 }
1340 if is_event_handler_attr(key) {
1341 #[cfg(feature = "tracing")]
1342 tracing::warn!(attr = key, "Dropping event-handler attribute from IR");
1343 return Ok(());
1344 }
1345 if let AttrValue::Valued(v) = val {
1346 if is_url_attr(key) && is_dangerous_url(v, tag) {
1347 #[cfg(feature = "tracing")]
1348 tracing::warn!(attr = key, "Dropping URL attribute with a dangerous scheme");
1349 return Ok(());
1350 }
1351 }
1352
1353 push_checked(out, " ", limit)?;
1354 push_checked(out, key, limit)?;
1355 if let AttrValue::Valued(v) = val {
1356 push_checked(out, "=\"", limit)?;
1357 push_escaped_attr(out, v, limit)?;
1358 push_checked(out, "\"", limit)?;
1359 }
1360 Ok(())
1361}
1362
1363fn emit_open_tag(
1369 out: &mut String,
1370 tag: &str,
1371 attrs: &[(&str, &str)],
1372 state: &mut WalkState,
1373 limit: usize,
1374) -> Result<(), IrError> {
1375 if !is_valid_name(tag) {
1376 return Err(IrError::InvalidName(tag.to_string()));
1377 }
1378 push_checked(out, "<", limit)?;
1379 push_checked(out, tag, limit)?;
1380
1381 for (key, val) in attrs {
1382 let value = if val.is_empty() {
1386 AttrValue::Bare
1387 } else {
1388 AttrValue::Valued(val)
1389 };
1390 emit_attr(out, tag, key, value, limit)?;
1391 }
1392
1393 if let Some(island) = state.pending_island.take() {
1395 push_checked(out, " data-forma-island=\"", limit)?;
1396 push_u16(out, island.id, limit)?;
1397 push_checked(out, "\" data-forma-component=\"", limit)?;
1398 push_escaped_attr(out, &island.component_name, limit)?;
1399 push_checked(out, "\" data-forma-status=\"pending\"", limit)?;
1400 let trigger_str = match island.trigger {
1401 IslandTrigger::Load => "load",
1402 IslandTrigger::Visible => "visible",
1403 IslandTrigger::Interaction => "interaction",
1404 IslandTrigger::Idle => "idle",
1405 };
1406 push_checked(out, " data-forma-hydrate=\"", limit)?;
1407 push_checked(out, trigger_str, limit)?;
1408 push_checked(out, "\"", limit)?;
1409 if let Some(ref props_json) = island.inline_props {
1410 push_checked(out, " data-forma-props=\"", limit)?;
1411 push_escaped_attr(out, props_json, limit)?;
1412 push_checked(out, "\"", limit)?;
1413 }
1414 }
1415
1416 if let Some(key) = state.pending_list_key.take() {
1418 push_checked(out, " data-forma-key=\"", limit)?;
1419 push_escaped_attr(out, &key, limit)?;
1420 push_checked(out, "\"", limit)?;
1421 }
1422
1423 state.pending_tag_close = true;
1425 state.pending_tag_name.clear();
1426 state.pending_tag_name.push_str(tag);
1427 Ok(())
1428}
1429
1430pub(crate) fn read_u16(data: &[u8], pos: usize) -> Result<u16, IrError> {
1436 if pos + 2 > data.len() {
1437 return Err(IrError::BufferTooShort {
1438 expected: pos + 2,
1439 actual: data.len(),
1440 });
1441 }
1442 Ok(u16::from_le_bytes(data[pos..pos + 2].try_into().unwrap()))
1443}
1444
1445pub(crate) fn read_u32(data: &[u8], pos: usize) -> Result<u32, IrError> {
1447 if pos + 4 > data.len() {
1448 return Err(IrError::BufferTooShort {
1449 expected: pos + 4,
1450 actual: data.len(),
1451 });
1452 }
1453 Ok(u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()))
1454}
1455
1456pub(crate) fn read_tag_with_attrs<'a>(
1461 ops: &[u8],
1462 pos: usize,
1463 strings: &'a crate::parser::StringTable,
1464) -> Result<(u32, Vec<(&'a str, &'a str)>, usize), IrError> {
1465 let tag_str_idx = read_u32(ops, pos)?;
1466 let attr_count = read_u16(ops, pos + 4)? as usize;
1467 let mut cursor = pos + 6; let mut attrs = Vec::with_capacity(attr_count.min(ops.len().saturating_sub(cursor) / 8));
1473 for _ in 0..attr_count {
1474 let key_idx = read_u32(ops, cursor)?;
1475 let val_idx = read_u32(ops, cursor + 4)?;
1476 let key = strings.get(key_idx)?;
1477 let val = strings.get(val_idx)?;
1478 attrs.push((key, val));
1479 cursor += 8;
1480 }
1481
1482 Ok((tag_str_idx, attrs, cursor))
1483}
1484
1485const MAX_ESCAPE_EXPANSION: usize = 6;
1492
1493fn push_checked(out: &mut String, s: &str, limit: usize) -> Result<(), IrError> {
1500 if out.len().saturating_add(s.len()) > limit {
1501 return Err(IrError::OutputLimitExceeded { limit });
1502 }
1503 out.push_str(s);
1504 Ok(())
1505}
1506
1507fn push_u16(out: &mut String, value: u16, limit: usize) -> Result<(), IrError> {
1513 let mut buf = [0u8; 5];
1515 let mut remaining = value;
1516 let mut start = buf.len();
1517 loop {
1518 start -= 1;
1519 buf[start] = b'0' + (remaining % 10) as u8;
1520 remaining /= 10;
1521 if remaining == 0 {
1522 break;
1523 }
1524 }
1525 let digits = core::str::from_utf8(&buf[start..]).expect("ASCII digits are valid UTF-8");
1526 push_checked(out, digits, limit)
1527}
1528
1529fn push_escaped_html(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
1531 if out
1533 .len()
1534 .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
1535 <= limit
1536 {
1537 for ch in text.chars() {
1538 match ch {
1539 '&' => out.push_str("&"),
1540 '<' => out.push_str("<"),
1541 '>' => out.push_str(">"),
1542 _ => out.push(ch),
1543 }
1544 }
1545 return Ok(());
1546 }
1547 for ch in text.chars() {
1548 match ch {
1549 '&' => push_checked(out, "&", limit)?,
1550 '<' => push_checked(out, "<", limit)?,
1551 '>' => push_checked(out, ">", limit)?,
1552 _ => {
1553 if out.len() >= limit {
1554 return Err(IrError::OutputLimitExceeded { limit });
1555 }
1556 out.push(ch);
1557 }
1558 }
1559 }
1560 Ok(())
1561}
1562
1563fn push_escaped_attr(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
1565 if out
1566 .len()
1567 .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
1568 <= limit
1569 {
1570 for ch in text.chars() {
1571 match ch {
1572 '&' => out.push_str("&"),
1573 '"' => out.push_str("""),
1574 '<' => out.push_str("<"),
1575 '>' => out.push_str(">"),
1576 _ => out.push(ch),
1577 }
1578 }
1579 return Ok(());
1580 }
1581 for ch in text.chars() {
1582 match ch {
1583 '&' => push_checked(out, "&", limit)?,
1584 '"' => push_checked(out, """, limit)?,
1585 '<' => push_checked(out, "<", limit)?,
1586 '>' => push_checked(out, ">", limit)?,
1587 _ => {
1588 if out.len() >= limit {
1589 return Err(IrError::OutputLimitExceeded { limit });
1590 }
1591 out.push(ch);
1592 }
1593 }
1594 }
1595 Ok(())
1596}
1597
1598#[cfg(test)]
1603mod tests {
1604 use super::*;
1605 use crate::parser::test_helpers::{
1606 build_minimal_ir, encode_close_tag, encode_list, encode_open_tag, encode_preload,
1607 encode_show_if, encode_switch, encode_text, encode_try, encode_void_tag,
1608 };
1609 use crate::slot::{SlotData, SlotValue};
1610
1611 fn encode_dyn_text(slot_id: u16, marker_id: u16) -> Vec<u8> {
1615 let mut buf = Vec::new();
1616 buf.push(0x05); buf.extend_from_slice(&slot_id.to_le_bytes());
1618 buf.extend_from_slice(&marker_id.to_le_bytes());
1619 buf
1620 }
1621
1622 fn encode_island_start(island_id: u16) -> Vec<u8> {
1624 let mut buf = vec![0x0B];
1625 buf.extend_from_slice(&island_id.to_le_bytes());
1626 buf
1627 }
1628
1629 fn encode_island_end(island_id: u16) -> Vec<u8> {
1631 let mut buf = vec![0x0C];
1632 buf.extend_from_slice(&island_id.to_le_bytes());
1633 buf
1634 }
1635
1636 fn encode_comment(str_idx: u32) -> Vec<u8> {
1638 let mut buf = vec![0x10];
1639 buf.extend_from_slice(&str_idx.to_le_bytes());
1640 buf
1641 }
1642
1643 fn walk_static(strings: &[&str], opcodes: &[u8]) -> String {
1645 let data = build_minimal_ir(strings, &[], opcodes, &[]);
1646 let module = IrModule::parse(&data).unwrap();
1647 let slots = SlotData::new(0);
1648 walk_to_html(&module, &slots).unwrap()
1649 }
1650
1651 fn walk_with_slots(
1653 strings: &[&str],
1654 slot_decls: &[(u16, u32, u8, u8, &[u8])],
1655 opcodes: &[u8],
1656 slots: &SlotData,
1657 ) -> String {
1658 let data = build_minimal_ir(strings, slot_decls, opcodes, &[]);
1659 let module = IrModule::parse(&data).unwrap();
1660 walk_to_html(&module, slots).unwrap()
1661 }
1662
1663 fn walk_with_islands(
1665 strings: &[&str],
1666 slot_decls: &[(u16, u32, u8, u8, &[u8])],
1667 island_decls: &[(u16, u8, u8, u32, u32, &[u16])],
1668 opcodes: &[u8],
1669 slots: &SlotData,
1670 ) -> String {
1671 let data = build_minimal_ir(strings, slot_decls, opcodes, island_decls);
1672 let module = IrModule::parse(&data).unwrap();
1673 walk_to_html(&module, slots).unwrap()
1674 }
1675
1676 fn encode_list_item_key(key_str_idx: u32) -> Vec<u8> {
1678 let mut buf = vec![0x11]; buf.extend_from_slice(&key_str_idx.to_le_bytes());
1680 buf
1681 }
1682
1683 fn encode_dyn_attr(attr_str_idx: u32, slot_id: u16) -> Vec<u8> {
1685 let mut bytes = vec![0x06]; bytes.extend_from_slice(&attr_str_idx.to_le_bytes());
1687 bytes.extend_from_slice(&slot_id.to_le_bytes());
1688 bytes
1689 }
1690
1691 #[test]
1694 fn walk_static_div() {
1695 let mut opcodes = Vec::new();
1698 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
1699 opcodes.extend_from_slice(&encode_text(3));
1700 opcodes.extend_from_slice(&encode_close_tag(0));
1701
1702 let html = walk_static(&["div", "class", "container", "Hello"], &opcodes);
1703 assert_eq!(html, r#"<div class="container">Hello</div>"#);
1704 }
1705
1706 #[test]
1709 fn walk_void_tag() {
1710 let opcodes = encode_void_tag(0, &[(1, 2)]);
1713
1714 let html = walk_static(&["input", "type", "email"], &opcodes);
1715 assert_eq!(html, r#"<input type="email">"#);
1716 }
1717
1718 #[test]
1721 fn walk_nested_elements() {
1722 let mut opcodes = Vec::new();
1725 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
1726 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
1727 opcodes.extend_from_slice(&encode_text(2));
1728 opcodes.extend_from_slice(&encode_close_tag(1));
1729 opcodes.extend_from_slice(&encode_close_tag(0));
1730
1731 let html = walk_static(&["div", "span", "Hi"], &opcodes);
1732 assert_eq!(html, "<div><span>Hi</span></div>");
1733 }
1734
1735 #[test]
1738 fn walk_text_escaping() {
1739 let opcodes = encode_text(0);
1741
1742 let html = walk_static(&["<script>alert('xss')</script>"], &opcodes);
1743 assert_eq!(html, "<script>alert('xss')</script>");
1744 }
1745
1746 #[test]
1749 fn walk_attr_escaping() {
1750 let opcodes = encode_open_tag(0, &[(1, 2)]);
1753
1754 let html = walk_static(&["div", "title", "a\"b"], &opcodes);
1755 assert_eq!(html, r#"<div title="a"b">"#);
1756 }
1757
1758 #[test]
1761 fn walk_empty_element() {
1762 let mut opcodes = Vec::new();
1764 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
1765 opcodes.extend_from_slice(&encode_close_tag(0));
1766
1767 let html = walk_static(&["div"], &opcodes);
1768 assert_eq!(html, "<div></div>");
1769 }
1770
1771 #[test]
1774 fn walk_dyn_text() {
1775 let opcodes = encode_dyn_text(0, 0);
1779
1780 let mut slots = SlotData::new(1);
1781 slots.set(0, SlotValue::Text("World".to_string()));
1782
1783 let html = walk_with_slots(&["greeting"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
1784 assert_eq!(html, "<!--f:t0-->World<!--/f:t0-->");
1785 }
1786
1787 #[test]
1788 fn walk_dyn_text_null_emits_zwsp() {
1789 let opcodes = encode_dyn_text(0, 0);
1792 let slots = SlotData::new(1); let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
1795 assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
1796 }
1797
1798 #[test]
1799 fn walk_dyn_text_empty_string_emits_zwsp() {
1800 let opcodes = encode_dyn_text(0, 0);
1802 let mut slots = SlotData::new(1);
1803 slots.set(0, SlotValue::Text(String::new()));
1804
1805 let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
1806 assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
1807 }
1808
1809 #[test]
1812 fn walk_dyn_text_escaping() {
1813 let opcodes = encode_dyn_text(0, 1);
1815
1816 let mut slots = SlotData::new(1);
1817 slots.set(0, SlotValue::Text("<b>bold</b>".to_string()));
1818
1819 let html = walk_with_slots(&["content"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
1820 assert_eq!(html, "<!--f:t1--><b>bold</b><!--/f:t1-->");
1821 }
1822
1823 #[test]
1826 fn walk_island_markers() {
1827 let mut opcodes = Vec::new();
1831 opcodes.extend_from_slice(&encode_island_start(0));
1832 opcodes.extend_from_slice(&encode_text(0));
1833 opcodes.extend_from_slice(&encode_island_end(0));
1834
1835 let slots = SlotData::new(0);
1836 let html = walk_with_islands(
1837 &["Hello"],
1838 &[],
1839 &[(0, 0x01, 0x01, 0, 0, &[])],
1840 &opcodes,
1841 &slots,
1842 );
1843 assert_eq!(html, "<!--f:i0-->Hello<!--/f:i0-->");
1844 }
1845
1846 #[test]
1849 fn walk_comment() {
1850 let opcodes = encode_comment(0);
1851
1852 let html = walk_static(&["This is a comment"], &opcodes);
1853 assert_eq!(html, "<!--This is a comment-->");
1854 }
1855
1856 #[test]
1859 fn walk_boolean_attr() {
1860 let opcodes = encode_void_tag(0, &[(1, 2)]);
1863
1864 let html = walk_static(&["input", "disabled", ""], &opcodes);
1865 assert_eq!(html, "<input disabled>");
1866 }
1867
1868 fn walk_dyn_attr_with(value: SlotValue) -> String {
1872 let mut opcodes = encode_void_tag(0, &[]);
1873 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
1874 let mut slots = SlotData::new(1);
1875 slots.set(0, value);
1876 walk_with_slots(
1877 &["input", "disabled"],
1878 &[(0, 1, 0x02, 0x00, &[])],
1879 &opcodes,
1880 &slots,
1881 )
1882 }
1883
1884 #[test]
1885 fn dyn_attr_bool_matches_the_client_renderer() {
1886 assert_eq!(
1894 walk_dyn_attr_with(SlotValue::Bool(true)),
1895 "<input disabled>",
1896 "true must render the bare attribute"
1897 );
1898 assert_eq!(
1899 walk_dyn_attr_with(SlotValue::Bool(false)),
1900 "<input>",
1901 "false must omit the attribute, not write disabled=\"false\""
1902 );
1903 assert_eq!(
1904 walk_dyn_attr_with(SlotValue::Null),
1905 "<input>",
1906 "null must omit the attribute"
1907 );
1908 }
1909
1910 #[test]
1911 fn dyn_attr_empty_string_is_a_value_not_an_absence() {
1912 assert_eq!(
1916 walk_dyn_attr_with(SlotValue::Text(String::new())),
1917 "<input disabled=\"\">"
1918 );
1919 assert_eq!(
1920 walk_dyn_attr_with(SlotValue::Text("yes".into())),
1921 "<input disabled=\"yes\">"
1922 );
1923 assert_eq!(
1925 walk_dyn_attr_with(SlotValue::Number(0.0)),
1926 "<input disabled=\"0\">"
1927 );
1928 }
1929
1930 #[test]
1933 fn walk_multiple_attrs() {
1934 let mut opcodes = Vec::new();
1937 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2), (3, 4), (5, 6)]));
1938 opcodes.extend_from_slice(&encode_close_tag(0));
1939
1940 let html = walk_static(
1941 &["div", "id", "main", "class", "container", "data-x", "42"],
1942 &opcodes,
1943 );
1944 assert_eq!(
1945 html,
1946 r#"<div id="main" class="container" data-x="42"></div>"#
1947 );
1948 }
1949
1950 #[test]
1953 fn walk_show_if_true_branch() {
1954 let then_ops = encode_text(1); let else_ops = encode_text(2); let opcodes = encode_show_if(0, &then_ops, &else_ops);
1960
1961 let mut slots = SlotData::new(1);
1962 slots.set(0, SlotValue::Bool(true));
1963
1964 let html = walk_with_slots(
1965 &["visible", "Yes", "No"],
1966 &[(0, 0, 0x02, 0x00, &[])], &opcodes,
1968 &slots,
1969 );
1970 assert!(html.contains("Yes"), "should contain then-branch text");
1971 assert!(!html.contains("No"), "should NOT contain else-branch text");
1972 assert!(html.contains("<!--f:s0-->"), "should have opening marker");
1973 assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
1974 }
1975
1976 #[test]
1979 fn walk_show_if_false_branch() {
1980 let then_ops = encode_text(1);
1983 let else_ops = encode_text(2);
1984 let opcodes = encode_show_if(0, &then_ops, &else_ops);
1985
1986 let mut slots = SlotData::new(1);
1987 slots.set(0, SlotValue::Bool(false));
1988
1989 let html = walk_with_slots(
1990 &["visible", "Yes", "No"],
1991 &[(0, 0, 0x02, 0x00, &[])],
1992 &opcodes,
1993 &slots,
1994 );
1995 assert!(html.contains("No"), "should contain else-branch text");
1996 assert!(!html.contains("Yes"), "should NOT contain then-branch text");
1997 assert!(html.contains("<!--f:s0-->"), "should have opening marker");
1998 assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
1999 }
2000
2001 #[test]
2004 fn walk_show_if_truthy_text() {
2005 let then_ops = encode_text(1);
2007 let else_ops = encode_text(2);
2008 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2009
2010 let mut slots = SlotData::new(1);
2011 slots.set(0, SlotValue::Text("hello".to_string()));
2012
2013 let html = walk_with_slots(
2014 &["greeting", "Shown", "Hidden"],
2015 &[(0, 0, 0x01, 0x00, &[])], &opcodes,
2017 &slots,
2018 );
2019 assert!(
2020 html.contains("Shown"),
2021 "truthy text should render then branch"
2022 );
2023 assert!(
2024 !html.contains("Hidden"),
2025 "truthy text should NOT render else branch"
2026 );
2027 }
2028
2029 #[test]
2032 fn walk_show_if_falsy_null() {
2033 let then_ops = encode_text(1);
2035 let else_ops = encode_text(2);
2036 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2037
2038 let slots = SlotData::new(1); let html = walk_with_slots(
2041 &["maybe", "Present", "Missing"],
2042 &[(0, 0, 0x01, 0x00, &[])],
2043 &opcodes,
2044 &slots,
2045 );
2046 assert!(html.contains("Missing"), "null should render else branch");
2047 assert!(
2048 !html.contains("Present"),
2049 "null should NOT render then branch"
2050 );
2051 }
2052
2053 #[test]
2056 fn walk_show_if_no_else() {
2057 let then_ops = encode_text(1);
2059 let else_ops: Vec<u8> = vec![]; let opcodes = encode_show_if(0, &then_ops, &else_ops);
2061
2062 let slots = SlotData::new(1); let html = walk_with_slots(
2065 &["flag", "Content"],
2066 &[(0, 0, 0x02, 0x00, &[])],
2067 &opcodes,
2068 &slots,
2069 );
2070 assert_eq!(
2071 html, "<!--f:s0--><!--/f:s0-->",
2072 "empty else should produce only markers"
2073 );
2074 }
2075
2076 #[test]
2079 fn walk_show_if_nested() {
2080 let inner_then = encode_text(4);
2084 let inner_else = encode_text(5);
2085 let inner_show_if = encode_show_if(1, &inner_then, &inner_else);
2086
2087 let outer_then = inner_show_if; let outer_else = encode_text(3);
2089 let opcodes = encode_show_if(0, &outer_then, &outer_else);
2090
2091 let mut slots = SlotData::new(2);
2092 slots.set(0, SlotValue::Bool(true)); slots.set(1, SlotValue::Bool(false)); let html = walk_with_slots(
2096 &[
2097 "outer",
2098 "inner",
2099 "outer-yes",
2100 "outer-no",
2101 "inner-yes",
2102 "inner-no",
2103 ],
2104 &[(0, 0, 0x02, 0x00, &[]), (1, 1, 0x02, 0x00, &[])],
2105 &opcodes,
2106 &slots,
2107 );
2108 assert!(
2110 html.contains("inner-no"),
2111 "nested: inner false should render inner else"
2112 );
2113 assert!(
2114 !html.contains("inner-yes"),
2115 "nested: inner true branch should not render"
2116 );
2117 assert!(
2118 !html.contains("outer-no"),
2119 "nested: outer else should not render"
2120 );
2121 assert!(
2123 html.contains("<!--f:s0-->"),
2124 "should have outer opening marker"
2125 );
2126 assert!(
2127 html.contains("<!--/f:s0-->"),
2128 "should have outer closing marker"
2129 );
2130 assert!(
2131 html.contains("<!--f:s1-->"),
2132 "should have inner opening marker"
2133 );
2134 assert!(
2135 html.contains("<!--/f:s1-->"),
2136 "should have inner closing marker"
2137 );
2138 }
2139
2140 #[test]
2143 fn walk_show_if_with_elements() {
2144 let mut then_ops = Vec::new();
2147 then_ops.extend_from_slice(&encode_open_tag(1, &[]));
2148 then_ops.extend_from_slice(&encode_text(2));
2149 then_ops.extend_from_slice(&encode_close_tag(1));
2150
2151 let else_ops = encode_text(3);
2152 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2153
2154 let mut slots = SlotData::new(1);
2155 slots.set(0, SlotValue::Bool(true));
2156
2157 let html = walk_with_slots(
2158 &["flag", "span", "Hello", "Bye"],
2159 &[(0, 0, 0x02, 0x00, &[])],
2160 &opcodes,
2161 &slots,
2162 );
2163 assert_eq!(
2164 html, "<!--f:s0--><span>Hello</span><!--/f:s0-->",
2165 "then branch should produce full HTML element"
2166 );
2167 }
2168
2169 #[test]
2172 fn walk_list_renders_items() {
2173 let body = encode_dyn_text(1, 0);
2177 let opcodes = encode_list(0, 1, &body);
2178
2179 let mut slots = SlotData::new(2);
2180 slots.set(
2181 0,
2182 SlotValue::array(vec![
2183 SlotValue::Text("Alice".to_string()),
2184 SlotValue::Text("Bob".to_string()),
2185 ]),
2186 );
2187
2188 let html = walk_with_slots(
2189 &["items", "item"],
2190 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
2192 &slots,
2193 );
2194 assert!(html.contains("Alice"), "should contain first item");
2195 assert!(html.contains("Bob"), "should contain second item");
2196 assert!(
2197 html.starts_with("<!--f:l0-->"),
2198 "should start with list open marker"
2199 );
2200 assert!(
2201 html.ends_with("<!--/f:l0-->"),
2202 "should end with list close marker"
2203 );
2204 }
2205
2206 #[test]
2209 fn walk_list_empty_array() {
2210 let body = encode_dyn_text(1, 0);
2213 let opcodes = encode_list(0, 1, &body);
2214
2215 let mut slots = SlotData::new(2);
2216 slots.set(0, SlotValue::array(vec![]));
2217
2218 let html = walk_with_slots(
2219 &["items", "item"],
2220 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2221 &opcodes,
2222 &slots,
2223 );
2224 assert_eq!(
2225 html, "<!--f:l0--><!--/f:l0-->",
2226 "empty array should produce only markers"
2227 );
2228 }
2229
2230 #[test]
2233 fn walk_list_with_elements() {
2234 let mut body = Vec::new();
2238 body.extend_from_slice(&encode_open_tag(2, &[]));
2239 body.extend_from_slice(&encode_dyn_text(1, 0));
2240 body.extend_from_slice(&encode_close_tag(2));
2241
2242 let opcodes = encode_list(0, 1, &body);
2243
2244 let mut slots = SlotData::new(2);
2245 slots.set(
2246 0,
2247 SlotValue::array(vec![
2248 SlotValue::Text("A".to_string()),
2249 SlotValue::Text("B".to_string()),
2250 ]),
2251 );
2252
2253 let html = walk_with_slots(
2254 &["items", "item", "li"],
2255 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2256 &opcodes,
2257 &slots,
2258 );
2259 assert!(
2260 html.contains("<li><!--f:t0-->A<!--/f:t0--></li><li><!--f:t0-->B<!--/f:t0--></li>"),
2261 "should render li elements with content, got: {html}"
2262 );
2263 }
2264
2265 #[test]
2268 fn walk_list_null_slot() {
2269 let body = encode_dyn_text(1, 0);
2272 let opcodes = encode_list(0, 1, &body);
2273
2274 let slots = SlotData::new(2); let html = walk_with_slots(
2277 &["items", "item"],
2278 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2279 &opcodes,
2280 &slots,
2281 );
2282 assert_eq!(
2283 html, "<!--f:l0--><!--/f:l0-->",
2284 "null slot should produce only markers"
2285 );
2286 }
2287
2288 #[test]
2291 fn walk_list_nested() {
2292 let inner_body = encode_dyn_text(2, 0);
2297 let inner_list = encode_list(1, 2, &inner_body);
2298 let opcodes = encode_list(0, 1, &inner_list);
2299
2300 let mut slots = SlotData::new(3);
2301 slots.set(
2302 0,
2303 SlotValue::array(vec![
2304 SlotValue::array(vec![
2305 SlotValue::Text("a".to_string()),
2306 SlotValue::Text("b".to_string()),
2307 ]),
2308 SlotValue::array(vec![SlotValue::Text("c".to_string())]),
2309 ]),
2310 );
2311
2312 let html = walk_with_slots(
2313 &["outer", "inner", "item"],
2314 &[
2315 (0, 0, 0x04, 0x00, &[]),
2316 (1, 1, 0x04, 0x00, &[]),
2317 (2, 2, 0x01, 0x00, &[]),
2318 ],
2319 &opcodes,
2320 &slots,
2321 );
2322 assert!(html.contains("a"), "should contain nested item 'a'");
2324 assert!(html.contains("b"), "should contain nested item 'b'");
2325 assert!(html.contains("c"), "should contain nested item 'c'");
2326 assert!(
2328 html.starts_with("<!--f:l0-->"),
2329 "should have outer list open marker"
2330 );
2331 assert!(
2332 html.ends_with("<!--/f:l0-->"),
2333 "should have outer list close marker"
2334 );
2335 assert!(
2337 html.contains("<!--f:l1-->"),
2338 "should have inner list open marker"
2339 );
2340 assert!(
2341 html.contains("<!--/f:l1-->"),
2342 "should have inner list close marker"
2343 );
2344 }
2345
2346 #[test]
2349 fn walk_list_depth_exceeded() {
2350 let innermost_body = encode_dyn_text(5, 0);
2354 let level5 = encode_list(4, 5, &innermost_body);
2356 let level4 = encode_list(3, 4, &level5);
2357 let level3 = encode_list(2, 3, &level4);
2358 let level2 = encode_list(1, 2, &level3);
2359 let level1 = encode_list(0, 1, &level2);
2360
2361 let mut slots = SlotData::new(6);
2362 slots.set(
2364 0,
2365 SlotValue::array(vec![SlotValue::array(vec![SlotValue::array(vec![
2366 SlotValue::array(vec![SlotValue::array(vec![SlotValue::Text(
2367 "deep".to_string(),
2368 )])]),
2369 ])])]),
2370 );
2371 let data = build_minimal_ir(
2374 &["s0", "s1", "s2", "s3", "s4", "s5"],
2375 &[
2376 (0, 0, 0x04, 0x00, &[]),
2377 (1, 1, 0x04, 0x00, &[]),
2378 (2, 2, 0x04, 0x00, &[]),
2379 (3, 3, 0x04, 0x00, &[]),
2380 (4, 4, 0x04, 0x00, &[]),
2381 (5, 5, 0x01, 0x00, &[]),
2382 ],
2383 &level1,
2384 &[],
2385 );
2386 let module = IrModule::parse(&data).unwrap();
2387 let result = walk_to_html(&module, &slots);
2388 assert!(result.is_err(), "should fail with depth exceeded");
2389 match result.unwrap_err() {
2390 IrError::ListDepthExceeded { max: 4 } => {}
2391 other => panic!("expected ListDepthExceeded {{ max: 4 }}, got {other:?}"),
2392 }
2393 }
2394
2395 #[test]
2398 fn walk_switch_matching_case() {
2399 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)]);
2405
2406 let mut slots = SlotData::new(1);
2407 slots.set(0, SlotValue::Text("about".to_string()));
2408
2409 let html = walk_with_slots(
2410 &["page", "home", "Home", "about", "About"],
2411 &[(0, 0, 0x01, 0x00, &[])],
2412 &opcodes,
2413 &slots,
2414 );
2415 assert!(html.contains("About"), "should contain matching case text");
2416 assert!(
2417 !html.contains("Home"),
2418 "should NOT contain non-matching case text"
2419 );
2420 }
2421
2422 #[test]
2425 fn walk_switch_no_match() {
2426 let case_home_body = encode_text(2);
2430 let case_about_body = encode_text(4);
2431 let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
2432
2433 let mut slots = SlotData::new(1);
2434 slots.set(0, SlotValue::Text("missing".to_string()));
2435
2436 let html = walk_with_slots(
2437 &["page", "home", "Home", "about", "About"],
2438 &[(0, 0, 0x01, 0x00, &[])],
2439 &opcodes,
2440 &slots,
2441 );
2442 assert!(
2443 html.is_empty(),
2444 "no match should produce empty output, got: {html}"
2445 );
2446 }
2447
2448 #[test]
2451 fn walk_switch_first_case() {
2452 let case_home_body = encode_text(2);
2455 let case_about_body = encode_text(4);
2456 let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
2457
2458 let mut slots = SlotData::new(1);
2459 slots.set(0, SlotValue::Text("home".to_string()));
2460
2461 let html = walk_with_slots(
2462 &["page", "home", "Home", "about", "About"],
2463 &[(0, 0, 0x01, 0x00, &[])],
2464 &opcodes,
2465 &slots,
2466 );
2467 assert!(html.contains("Home"), "should contain first case text");
2468 assert!(
2469 !html.contains("About"),
2470 "should NOT contain second case text"
2471 );
2472 }
2473
2474 #[test]
2475 fn walk_switch_stops_at_the_first_match() {
2476 let opcodes = encode_switch(0, &[(1, &encode_text(2)), (1, &encode_text(3))]);
2479
2480 let mut slots = SlotData::new(1);
2481 slots.set(0, SlotValue::Text("home".to_string()));
2482
2483 let html = walk_with_slots(
2484 &["page", "home", "FIRST", "SECOND"],
2485 &[(0, 0, 0x01, 0x00, &[])],
2486 &opcodes,
2487 &slots,
2488 );
2489 assert_eq!(html, "FIRST");
2490 }
2491
2492 #[test]
2495 fn walk_switch_with_elements() {
2496 let mut active_body = Vec::new();
2499 active_body.extend_from_slice(&encode_open_tag(2, &[]));
2500 active_body.extend_from_slice(&encode_text(3));
2501 active_body.extend_from_slice(&encode_close_tag(2));
2502
2503 let inactive_body = encode_text(5);
2504 let opcodes = encode_switch(0, &[(1, &active_body), (4, &inactive_body)]);
2505
2506 let mut slots = SlotData::new(1);
2507 slots.set(0, SlotValue::Text("active".to_string()));
2508
2509 let html = walk_with_slots(
2510 &["page", "active", "span", "Active", "inactive", "Inactive"],
2511 &[(0, 0, 0x01, 0x00, &[])],
2512 &opcodes,
2513 &slots,
2514 );
2515 assert_eq!(
2516 html, "<span>Active</span>",
2517 "should produce full HTML element from matching case"
2518 );
2519 }
2520
2521 #[test]
2524 fn walk_try_renders_main() {
2525 let main_ops = encode_text(0);
2528 let fallback_ops = encode_text(1);
2529 let opcodes = encode_try(&main_ops, &fallback_ops);
2530
2531 let html = walk_static(&["Main", "Fallback"], &opcodes);
2532 assert!(html.contains("Main"), "should contain main body text");
2533 assert!(
2534 !html.contains("Fallback"),
2535 "should NOT contain fallback text"
2536 );
2537 }
2538
2539 #[test]
2542 fn walk_preload_font() {
2543 let opcodes = encode_preload(1, 0);
2546
2547 let html = walk_static(&["/_assets/dm-mono.woff2"], &opcodes);
2548 assert!(
2549 html.contains(r#"<link rel="preload" href="/_assets/dm-mono.woff2" as="font" type="font/woff2" crossorigin>"#),
2550 "should produce preload link for font, got: {html}"
2551 );
2552 }
2553
2554 #[test]
2557 fn walk_preload_style() {
2558 let opcodes = encode_preload(2, 0);
2561
2562 let html = walk_static(&["/_assets/styles.css"], &opcodes);
2563 assert!(
2564 html.contains(r#"as="style""#),
2565 "should contain as=\"style\", got: {html}"
2566 );
2567 assert!(
2568 html.contains(r#"href="/_assets/styles.css""#),
2569 "should contain correct href, got: {html}"
2570 );
2571 }
2572
2573 #[test]
2583 fn roundtrip_page_structure() {
2584 let mut opcodes = Vec::new();
2592 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(
2606 &[
2607 "html", "lang", "en", "head", "meta", "charset", "utf-8", "title", "Test", "body",
2608 "div", "id", "app",
2609 ],
2610 &[],
2611 &opcodes,
2612 &[],
2613 );
2614 let module = IrModule::parse(&ir).unwrap();
2615 let slots = SlotData::new(0);
2616 let html = walk_to_html(&module, &slots).unwrap();
2617 assert_eq!(
2618 html,
2619 r#"<html lang="en"><head><meta charset="utf-8"><title>Test</title></head><body><div id="app"></div></body></html>"#
2620 );
2621 }
2622
2623 #[test]
2626 fn roundtrip_dynamic_greeting() {
2627 let mut opcodes = Vec::new();
2633 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(
2638 &["div", "class", "greeting", "content"],
2639 &[(0, 3, 0x01, 0x00, &[])], &opcodes,
2641 &[],
2642 );
2643 let module = IrModule::parse(&ir).unwrap();
2644 let mut slots = SlotData::new(1);
2645 slots.set(0, SlotValue::Text("Hello, World".into()));
2646 let html = walk_to_html(&module, &slots).unwrap();
2647 assert_eq!(
2648 html,
2649 r#"<div class="greeting"><!--f:t0-->Hello, World<!--/f:t0--></div>"#
2650 );
2651 }
2652
2653 #[test]
2656 fn roundtrip_conditional_content() {
2657 let mut then_ops = Vec::new();
2664 then_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
2665 then_ops.extend_from_slice(&encode_text(4));
2666 then_ops.extend_from_slice(&encode_close_tag(1));
2667
2668 let mut else_ops = Vec::new();
2669 else_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
2670 else_ops.extend_from_slice(&encode_text(5));
2671 else_ops.extend_from_slice(&encode_close_tag(1));
2672
2673 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2674
2675 let ir = build_minimal_ir(
2676 &["auth", "span", "class", "badge", "Logged In", "Guest"],
2677 &[(0, 0, 0x02, 0x00, &[])], &opcodes,
2679 &[],
2680 );
2681
2682 {
2684 let module = IrModule::parse(&ir).unwrap();
2685 let mut slots = SlotData::new(1);
2686 slots.set(0, SlotValue::Bool(true));
2687 let html = walk_to_html(&module, &slots).unwrap();
2688 assert_eq!(
2689 html,
2690 r#"<!--f:s0--><span class="badge">Logged In</span><!--/f:s0-->"#
2691 );
2692 }
2693
2694 {
2696 let module = IrModule::parse(&ir).unwrap();
2697 let mut slots = SlotData::new(1);
2698 slots.set(0, SlotValue::Bool(false));
2699 let html = walk_to_html(&module, &slots).unwrap();
2700 assert_eq!(
2701 html,
2702 r#"<!--f:s0--><span class="badge">Guest</span><!--/f:s0-->"#
2703 );
2704 }
2705 }
2706
2707 #[test]
2710 fn roundtrip_list_rendering() {
2711 let mut body = Vec::new();
2717 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);
2722
2723 let ir = build_minimal_ir(
2724 &["items", "item", "li"],
2725 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
2727 &[],
2728 );
2729 let module = IrModule::parse(&ir).unwrap();
2730 let mut slots = SlotData::new(2);
2731 slots.set(
2732 0,
2733 SlotValue::array(vec![
2734 SlotValue::Text("Alice".into()),
2735 SlotValue::Text("Bob".into()),
2736 SlotValue::Text("Charlie".into()),
2737 ]),
2738 );
2739 let html = walk_to_html(&module, &slots).unwrap();
2740 assert_eq!(
2741 html,
2742 "<!--f:l0-->\
2743 <li><!--f:t0-->Alice<!--/f:t0--></li>\
2744 <li><!--f:t0-->Bob<!--/f:t0--></li>\
2745 <li><!--f:t0-->Charlie<!--/f:t0--></li>\
2746 <!--/f:l0-->"
2747 );
2748 }
2749
2750 #[test]
2753 fn roundtrip_switch_view() {
2754 let mut home_body = Vec::new();
2759 home_body.extend_from_slice(&encode_open_tag(2, &[]));
2760 home_body.extend_from_slice(&encode_text(3));
2761 home_body.extend_from_slice(&encode_close_tag(2));
2762
2763 let mut about_body = Vec::new();
2764 about_body.extend_from_slice(&encode_open_tag(2, &[]));
2765 about_body.extend_from_slice(&encode_text(5));
2766 about_body.extend_from_slice(&encode_close_tag(2));
2767
2768 let opcodes = encode_switch(0, &[(1, &home_body), (4, &about_body)]);
2769
2770 let ir = build_minimal_ir(
2771 &["page", "home", "h1", "Home", "about", "About"],
2772 &[(0, 0, 0x01, 0x00, &[])], &opcodes,
2774 &[],
2775 );
2776 let module = IrModule::parse(&ir).unwrap();
2777 let mut slots = SlotData::new(1);
2778 slots.set(0, SlotValue::Text("about".into()));
2779 let html = walk_to_html(&module, &slots).unwrap();
2780 assert_eq!(html, "<h1>About</h1>");
2781 }
2782
2783 #[test]
2786 fn roundtrip_nested_structure() {
2787 let mut then_ops = Vec::new();
2808 then_ops.extend_from_slice(&encode_open_tag(4, &[]));
2809 then_ops.extend_from_slice(&encode_text(5));
2810 then_ops.extend_from_slice(&encode_close_tag(4));
2811
2812 let show_if_ops = encode_show_if(0, &then_ops, &[]);
2813
2814 let mut list_body = Vec::new();
2816 list_body.extend_from_slice(&encode_open_tag(9, &[]));
2817 list_body.extend_from_slice(&encode_dyn_text(2, 0));
2818 list_body.extend_from_slice(&encode_close_tag(9));
2819
2820 let list_ops = encode_list(1, 2, &list_body);
2821
2822 let mut opcodes = Vec::new();
2824 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(
2832 &[
2833 "nav", "class", "main-nav", "auth", "span", "Welcome", "items", "item", "ul", "li",
2834 ],
2835 &[
2836 (0, 3, 0x02, 0x00, &[]), (1, 6, 0x04, 0x00, &[]), (2, 7, 0x01, 0x00, &[]), ],
2840 &opcodes,
2841 &[],
2842 );
2843 let module = IrModule::parse(&ir).unwrap();
2844 let mut slots = SlotData::new(3);
2845 slots.set(0, SlotValue::Bool(true));
2846 slots.set(
2847 1,
2848 SlotValue::array(vec![
2849 SlotValue::Text("Home".into()),
2850 SlotValue::Text("About".into()),
2851 ]),
2852 );
2853
2854 let html = walk_to_html(&module, &slots).unwrap();
2855 assert_eq!(
2856 html,
2857 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>"#
2858 );
2859 }
2860
2861 #[test]
2868 fn walk_island_attrs_on_open_tag() {
2869 let mut opcodes = Vec::new();
2873 opcodes.extend_from_slice(&encode_island_start(0));
2874 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2875 opcodes.extend_from_slice(&encode_text(1));
2876 opcodes.extend_from_slice(&encode_close_tag(0));
2877 opcodes.extend_from_slice(&encode_island_end(0));
2878
2879 let slots = SlotData::new(0);
2880 let html = walk_with_islands(
2881 &["div", "Hello", "AuthForm"],
2882 &[],
2883 &[(0, 0x01, 0x01, 2, 0, &[])],
2885 &opcodes,
2886 &slots,
2887 );
2888 assert_eq!(
2889 html,
2890 r#"<!--f:i0--><div data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load">Hello</div><!--/f:i0-->"#
2891 );
2892 }
2893
2894 #[test]
2897 fn walk_island_attrs_with_existing_attrs() {
2898 let mut opcodes = Vec::new();
2901 opcodes.extend_from_slice(&encode_island_start(0));
2902 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
2903 opcodes.extend_from_slice(&encode_close_tag(0));
2904 opcodes.extend_from_slice(&encode_island_end(0));
2905
2906 let slots = SlotData::new(0);
2907 let html = walk_with_islands(
2908 &["div", "class", "form", "AuthForm"],
2909 &[],
2910 &[(0, 0x01, 0x01, 3, 0, &[])],
2911 &opcodes,
2912 &slots,
2913 );
2914 assert_eq!(
2915 html,
2916 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-->"#
2917 );
2918 }
2919
2920 #[test]
2923 fn walk_island_attrs_on_void_tag() {
2924 let mut opcodes = Vec::new();
2927 opcodes.extend_from_slice(&encode_island_start(0));
2928 opcodes.extend_from_slice(&encode_void_tag(0, &[]));
2929 opcodes.extend_from_slice(&encode_island_end(0));
2930
2931 let slots = SlotData::new(0);
2932 let html = walk_with_islands(
2933 &["input", "Widget"],
2934 &[],
2935 &[(0, 0x01, 0x01, 1, 0, &[])],
2936 &opcodes,
2937 &slots,
2938 );
2939 assert_eq!(
2940 html,
2941 r#"<!--f:i0--><input data-forma-island="0" data-forma-component="Widget" data-forma-status="pending" data-forma-hydrate="load"><!--/f:i0-->"#
2942 );
2943 }
2944
2945 #[test]
2948 fn walk_inline_props_attribute() {
2949 let mut opcodes = Vec::new();
2954 opcodes.extend_from_slice(&encode_island_start(0));
2955 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2956 opcodes.extend_from_slice(&encode_close_tag(0));
2957 opcodes.extend_from_slice(&encode_island_end(0));
2958
2959 let mut slots = SlotData::new(2);
2960 slots.set(0, SlotValue::Text("Hello".to_string()));
2961 slots.set(1, SlotValue::Number(42.0));
2962
2963 let html = walk_with_islands(
2964 &["div", "MyComponent", "title", "count"],
2965 &[
2966 (0, 2, 0x01, 0x00, &[]), (1, 3, 0x03, 0x00, &[]), ],
2969 &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
2971 &opcodes,
2972 &slots,
2973 );
2974 assert!(
2976 html.contains("data-forma-props="),
2977 "should contain data-forma-props, got: {html}"
2978 );
2979 assert!(
2980 html.contains(r#"data-forma-island="0""#),
2981 "should contain island id, got: {html}"
2982 );
2983 assert!(
2984 html.contains(r#"data-forma-component="MyComponent""#),
2985 "should contain component name, got: {html}"
2986 );
2987 let marker = "data-forma-props=\"";
2993 let props_start = html.find(marker).unwrap() + marker.len();
2994 let props_end = html[props_start..].find('"').unwrap() + props_start;
2995 let raw_attr = &html[props_start..props_end];
2996 let decoded = raw_attr
2997 .replace(""", "\"")
2998 .replace("&", "&")
2999 .replace("<", "<")
3000 .replace(">", ">");
3001 let props_json: serde_json::Value = serde_json::from_str(&decoded).unwrap();
3002 assert_eq!(props_json["title"], "Hello");
3003 assert_eq!(props_json["count"], 42);
3004 }
3005
3006 #[test]
3009 fn walk_list_item_key_on_first_open_tag() {
3010 let mut opcodes = Vec::new();
3013 opcodes.extend_from_slice(&encode_list_item_key(1));
3014 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3015 opcodes.extend_from_slice(&encode_text(2));
3016 opcodes.extend_from_slice(&encode_close_tag(0));
3017
3018 let html = walk_static(&["li", "user-123", "item"], &opcodes);
3019 assert_eq!(html, r#"<li data-forma-key="user-123">item</li>"#);
3020 }
3021
3022 #[test]
3025 fn walk_list_item_key_on_void_tag() {
3026 let mut opcodes = Vec::new();
3029 opcodes.extend_from_slice(&encode_list_item_key(1));
3030 opcodes.extend_from_slice(&encode_void_tag(0, &[]));
3031
3032 let html = walk_static(&["hr", "key-42"], &opcodes);
3033 assert_eq!(html, r#"<hr data-forma-key="key-42">"#);
3034 }
3035
3036 #[test]
3039 fn walk_list_item_key_with_attrs() {
3040 let mut opcodes = Vec::new();
3043 opcodes.extend_from_slice(&encode_list_item_key(3));
3044 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
3045 opcodes.extend_from_slice(&encode_text(4));
3046 opcodes.extend_from_slice(&encode_close_tag(0));
3047
3048 let html = walk_static(&["li", "class", "item", "k1", "content"], &opcodes);
3049 assert_eq!(html, r#"<li class="item" data-forma-key="k1">content</li>"#);
3050 }
3051
3052 #[test]
3055 fn walk_script_tag_props_mode() {
3056 let mut opcodes = Vec::new();
3060 opcodes.extend_from_slice(&encode_island_start(0));
3061 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3062 opcodes.extend_from_slice(&encode_close_tag(0));
3063 opcodes.extend_from_slice(&encode_island_end(0));
3064
3065 let mut slots = SlotData::new(1);
3066 slots.set(0, SlotValue::Text("Click me".to_string()));
3067
3068 let html = walk_with_islands(
3069 &["div", "Counter", "label"],
3070 &[(0, 2, 0x01, 0x00, &[])], &[(0, 0x01, 0x02, 1, 0, &[0])],
3073 &opcodes,
3074 &slots,
3075 );
3076 assert!(
3078 !html.contains("data-forma-props"),
3079 "ScriptTag mode should not emit inline props, got: {html}"
3080 );
3081 assert!(
3083 html.contains(r#"data-forma-island="0""#),
3084 "should contain island id"
3085 );
3086 assert!(
3088 html.contains(r#"<script id="__forma_islands" type="application/json">"#),
3089 "should contain script tag, got: {html}"
3090 );
3091 assert!(html.contains("</script>"), "should close script tag");
3092 let script_start =
3094 html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
3095 let script_end = html[script_start..].find("</script>").unwrap() + script_start;
3096 let json: serde_json::Value =
3097 serde_json::from_str(&html[script_start..script_end]).unwrap();
3098 assert_eq!(json["0"]["label"], "Click me");
3099 }
3100
3101 #[test]
3104 fn walk_deferred_props_mode() {
3105 let mut opcodes = Vec::new();
3109 opcodes.extend_from_slice(&encode_island_start(0));
3110 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3111 opcodes.extend_from_slice(&encode_close_tag(0));
3112 opcodes.extend_from_slice(&encode_island_end(0));
3113
3114 let mut slots = SlotData::new(1);
3115 slots.set(0, SlotValue::Text("ignored".to_string()));
3116
3117 let html = walk_with_islands(
3118 &["div", "LazyWidget", "data"],
3119 &[(0, 2, 0x01, 0x00, &[])],
3120 &[(0, 0x01, 0x03, 1, 0, &[0])],
3122 &opcodes,
3123 &slots,
3124 );
3125 assert!(
3126 !html.contains("data-forma-props"),
3127 "Deferred mode should not emit inline props, got: {html}"
3128 );
3129 assert!(
3130 !html.contains("__forma_islands"),
3131 "Deferred mode should not emit script tag, got: {html}"
3132 );
3133 assert!(
3135 html.contains(r#"data-forma-island="0""#),
3136 "should contain island id"
3137 );
3138 assert!(
3139 html.contains(r#"data-forma-component="LazyWidget""#),
3140 "should contain component name"
3141 );
3142 }
3143
3144 #[test]
3147 fn walk_island_no_entry_in_table() {
3148 let mut opcodes = Vec::new();
3153 opcodes.extend_from_slice(&encode_island_start(5));
3154 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3155 opcodes.extend_from_slice(&encode_text(1));
3156 opcodes.extend_from_slice(&encode_close_tag(0));
3157 opcodes.extend_from_slice(&encode_island_end(5));
3158
3159 let data = build_minimal_ir(&["div", "Hello"], &[], &opcodes, &[]);
3160 assert_eq!(
3161 IrModule::parse(&data).unwrap_err(),
3162 IrError::UnknownIslandId(5)
3163 );
3164 }
3165
3166 #[test]
3167 fn walk_nested_island_start_before_a_root_element_is_an_error() {
3168 let mut opcodes = Vec::new();
3172 opcodes.extend_from_slice(&encode_island_start(0));
3173 opcodes.extend_from_slice(&encode_island_start(1));
3174 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3175 opcodes.extend_from_slice(&encode_close_tag(0));
3176 opcodes.extend_from_slice(&encode_island_end(1));
3177 opcodes.extend_from_slice(&encode_island_end(0));
3178
3179 let slots = SlotData::new(0);
3180 let data = build_minimal_ir(
3181 &["div", "Outer", "Inner"],
3182 &[],
3183 &opcodes,
3184 &[(0, 0x01, 0x01, 1, 0, &[]), (1, 0x01, 0x01, 2, 3, &[])],
3185 );
3186 let module = IrModule::parse(&data).unwrap();
3187 assert_eq!(
3188 walk_to_html(&module, &slots).unwrap_err(),
3189 IrError::NestedIslandRoot { outer: 0, inner: 1 }
3190 );
3191 }
3192
3193 #[test]
3194 fn walk_island_nested_inside_another_islands_root_is_fine() {
3195 let mut opcodes = Vec::new();
3197 opcodes.extend_from_slice(&encode_island_start(0));
3198 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3199 opcodes.extend_from_slice(&encode_island_start(1));
3200 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3201 opcodes.extend_from_slice(&encode_close_tag(0));
3202 opcodes.extend_from_slice(&encode_island_end(1));
3203 opcodes.extend_from_slice(&encode_close_tag(0));
3204 opcodes.extend_from_slice(&encode_island_end(0));
3205
3206 let slots = SlotData::new(0);
3207 let inner_offset = (encode_island_start(0).len() + encode_open_tag(0, &[]).len()) as u32;
3208 let html = walk_with_islands(
3209 &["div", "Outer", "Inner"],
3210 &[],
3211 &[
3212 (0, 0x01, 0x01, 1, 0, &[]),
3213 (1, 0x01, 0x01, 2, inner_offset, &[]),
3214 ],
3215 &opcodes,
3216 &slots,
3217 );
3218 assert!(html.contains(r#"data-forma-component="Outer""#));
3219 assert!(html.contains(r#"data-forma-component="Inner""#));
3220 }
3221
3222 #[test]
3225 fn walk_inline_props_empty_slots() {
3226 let mut opcodes = Vec::new();
3229 opcodes.extend_from_slice(&encode_island_start(0));
3230 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3231 opcodes.extend_from_slice(&encode_close_tag(0));
3232 opcodes.extend_from_slice(&encode_island_end(0));
3233
3234 let slots = SlotData::new(0);
3235 let html = walk_with_islands(
3236 &["div", "Simple"],
3237 &[],
3238 &[(0, 0x01, 0x01, 1, 0, &[])], &opcodes,
3240 &slots,
3241 );
3242 assert!(
3243 !html.contains("data-forma-props"),
3244 "empty slot_ids should not emit props, got: {html}"
3245 );
3246 assert!(
3247 html.contains(r#"data-forma-island="0""#),
3248 "should contain island id"
3249 );
3250 }
3251
3252 fn walk_island_with_prop_payload(payload: &str) -> String {
3256 let mut opcodes = Vec::new();
3257 opcodes.extend_from_slice(&encode_island_start(0));
3258 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3259 opcodes.extend_from_slice(&encode_close_tag(0));
3260 opcodes.extend_from_slice(&encode_island_end(0));
3261
3262 let mut slots = SlotData::new(1);
3263 slots.set(0, SlotValue::Text(payload.to_string()));
3264 walk_with_islands(
3265 &["div", "Big", "blob"],
3266 &[(0, 2, 0x01, 0x00, &[])],
3267 &[(0, 0x01, 0x01, 1, 0, &[0])],
3268 &opcodes,
3269 &slots,
3270 )
3271 }
3272
3273 #[test]
3274 fn island_props_keep_the_first_slot_for_a_duplicated_name() {
3275 let mut opcodes = Vec::new();
3279 opcodes.extend_from_slice(&encode_island_start(0));
3280 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3281 opcodes.extend_from_slice(&encode_close_tag(0));
3282 opcodes.extend_from_slice(&encode_island_end(0));
3283
3284 let mut slots = SlotData::new(2);
3285 slots.set(0, SlotValue::Text("first".into()));
3286 slots.set(1, SlotValue::Text("second".into()));
3287
3288 let html = walk_with_islands(
3289 &["div", "Comp", "dup"],
3290 &[(0, 2, 0x01, 0x00, &[]), (1, 2, 0x01, 0x00, &[])],
3291 &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
3292 &opcodes,
3293 &slots,
3294 );
3295 assert!(
3296 html.contains(r#"data-forma-props="{"dup":"first"}""#),
3297 "expected the first slot to win, got: {html}"
3298 );
3299 }
3300
3301 #[test]
3302 fn inline_props_under_the_ceiling_stay_inline() {
3303 let html = walk_island_with_prop_payload("small");
3304 assert!(html.contains(r#"data-forma-props="{"blob":"small"}""#));
3305 assert!(!html.contains("__forma_islands"));
3306 }
3307
3308 #[test]
3309 fn inline_props_over_the_ceiling_spill_to_the_shared_block() {
3310 let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
3316 let html = walk_island_with_prop_payload(&payload);
3317 assert!(
3318 !html.contains("data-forma-props"),
3319 "over-size props must not stay in the attribute"
3320 );
3321 assert!(
3322 html.contains(r#"<script id="__forma_islands" type="application/json">"#),
3323 "over-size props must land in the shared block, got: {}",
3324 &html[..html.len().min(200)]
3325 );
3326 assert!(html.contains(&payload), "the payload itself must survive");
3327 }
3328
3329 #[test]
3330 fn island_fragment_keeps_over_size_props_inline() {
3331 let mut opcodes = Vec::new();
3335 opcodes.extend_from_slice(&encode_island_start(0));
3336 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3337 opcodes.extend_from_slice(&encode_close_tag(0));
3338 opcodes.extend_from_slice(&encode_island_end(0));
3339
3340 let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
3341 let mut slots = SlotData::new(1);
3342 slots.set(0, SlotValue::Text(payload));
3343
3344 let data = build_minimal_ir(
3345 &["div", "Big", "blob"],
3346 &[(0, 2, 0x01, 0x00, &[])],
3347 &opcodes,
3348 &[(0, 0x01, 0x01, 1, 0, &[0])],
3349 );
3350 let module = IrModule::parse(&data).unwrap();
3351 let fragment = walk_island(&module, &slots, 0).unwrap();
3352 assert!(fragment.contains("data-forma-props"));
3353 assert!(!fragment.contains("__forma_islands"));
3354 }
3355
3356 #[test]
3359 fn walk_island_and_list_key_combined() {
3360 let mut opcodes = Vec::new();
3364 opcodes.extend_from_slice(&encode_island_start(0));
3365 opcodes.extend_from_slice(&encode_list_item_key(2));
3366 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3367 opcodes.extend_from_slice(&encode_close_tag(0));
3368 opcodes.extend_from_slice(&encode_island_end(0));
3369
3370 let slots = SlotData::new(0);
3371 let html = walk_with_islands(
3372 &["div", "Card", "item-1"],
3373 &[],
3374 &[(0, 0x01, 0x01, 1, 0, &[])],
3375 &opcodes,
3376 &slots,
3377 );
3378 assert!(
3379 html.contains(r#"data-forma-island="0""#),
3380 "should contain island attrs"
3381 );
3382 assert!(
3383 html.contains(r#"data-forma-key="item-1""#),
3384 "should contain list key attr, got: {html}"
3385 );
3386 }
3387
3388 #[test]
3391 fn walk_multiple_islands_script_tag() {
3392 let mut opcodes = Vec::new();
3396 opcodes.extend_from_slice(&encode_island_start(0));
3398 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3399 opcodes.extend_from_slice(&encode_close_tag(0));
3400 opcodes.extend_from_slice(&encode_island_end(0));
3401 let island1_offset = opcodes.len() as u32;
3403 opcodes.extend_from_slice(&encode_island_start(1));
3404 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3405 opcodes.extend_from_slice(&encode_close_tag(1));
3406 opcodes.extend_from_slice(&encode_island_end(1));
3407
3408 let mut slots = SlotData::new(2);
3409 slots.set(0, SlotValue::Text("hello".to_string()));
3410 slots.set(1, SlotValue::Bool(true));
3411
3412 let html = walk_with_islands(
3413 &["div", "span", "CompA", "CompB", "msg", "flag"],
3414 &[
3415 (0, 4, 0x01, 0x00, &[]), (1, 5, 0x02, 0x00, &[]), ],
3418 &[
3419 (0, 0x01, 0x02, 2, 0, &[0]), (1, 0x01, 0x02, 3, island1_offset, &[1]), ],
3422 &opcodes,
3423 &slots,
3424 );
3425 let script_start =
3427 html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
3428 let script_end = html[script_start..].find("</script>").unwrap() + script_start;
3429 let json: serde_json::Value =
3430 serde_json::from_str(&html[script_start..script_end]).unwrap();
3431 assert_eq!(json["0"]["msg"], "hello");
3432 assert_eq!(json["1"]["flag"], true);
3433 }
3434
3435 #[test]
3438 fn walk_list_item_key_consumed_once() {
3439 let mut opcodes = Vec::new();
3443 opcodes.extend_from_slice(&encode_list_item_key(2));
3444 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3445 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3446 opcodes.extend_from_slice(&encode_text(3));
3447 opcodes.extend_from_slice(&encode_close_tag(1));
3448 opcodes.extend_from_slice(&encode_close_tag(0));
3449
3450 let html = walk_static(&["li", "span", "abc", "text"], &opcodes);
3451 assert_eq!(html, r#"<li data-forma-key="abc"><span>text</span></li>"#);
3452 }
3453
3454 #[test]
3457 fn slot_value_to_json_all_types() {
3458 assert_eq!(SlotValue::Null.to_json(), serde_json::Value::Null);
3460 assert_eq!(
3461 SlotValue::Text("hello".to_string()).to_json(),
3462 serde_json::Value::String("hello".to_string())
3463 );
3464 assert_eq!(
3465 SlotValue::Bool(true).to_json(),
3466 serde_json::Value::Bool(true)
3467 );
3468 assert_eq!(
3469 SlotValue::Bool(false).to_json(),
3470 serde_json::Value::Bool(false)
3471 );
3472 assert_eq!(SlotValue::Number(42.0).to_json(), serde_json::json!(42));
3473 assert_eq!(SlotValue::Number(3.15).to_json(), serde_json::json!(3.15));
3474 assert_eq!(
3475 SlotValue::array(vec![
3476 SlotValue::Text("a".to_string()),
3477 SlotValue::Number(1.0)
3478 ])
3479 .to_json(),
3480 serde_json::json!(["a", 1])
3481 );
3482 assert_eq!(
3483 SlotValue::object(vec![(
3484 "key".to_string(),
3485 SlotValue::Text("val".to_string())
3486 ),])
3487 .to_json(),
3488 serde_json::json!({"key": "val"})
3489 );
3490 }
3491
3492 #[test]
3495 fn dyn_attr_emits_attribute_with_value() {
3496 let mut opcodes = Vec::new();
3500 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3501 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
3502 opcodes.extend_from_slice(&encode_close_tag(0));
3503
3504 let mut slots = SlotData::new(1);
3505 slots.set(0, SlotValue::Text("password".to_string()));
3506
3507 let html = walk_with_slots(
3508 &["input", "type", "input_type"],
3509 &[(0, 2, 0x01, 0x01, b"password")], &opcodes,
3511 &slots,
3512 );
3513 assert_eq!(html, r#"<input type="password"></input>"#);
3514 }
3515
3516 #[test]
3519 fn dyn_attr_skips_when_slot_null() {
3520 let mut opcodes = Vec::new();
3524 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3525 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
3526 opcodes.extend_from_slice(&encode_close_tag(0));
3527
3528 let slots = SlotData::new(1); let html = walk_with_slots(
3531 &["div", "class", "cls"],
3532 &[(0, 2, 0x01, 0x00, &[])],
3533 &opcodes,
3534 &slots,
3535 );
3536 assert_eq!(html, "<div></div>");
3537 }
3538
3539 #[test]
3542 fn dyn_attr_multiple_on_same_element() {
3543 let mut opcodes = Vec::new();
3546 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3547 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
3548 opcodes.extend_from_slice(&encode_dyn_attr(2, 1));
3549 opcodes.extend_from_slice(&encode_close_tag(0));
3550
3551 let mut slots = SlotData::new(2);
3552 slots.set(0, SlotValue::Text("email".to_string()));
3553 slots.set(1, SlotValue::Text("form-input".to_string()));
3554
3555 let html = walk_with_slots(
3556 &["input", "type", "class", "slot_type", "slot_class"],
3557 &[(0, 3, 0x01, 0x01, b"text"), (1, 4, 0x01, 0x01, b"")],
3558 &opcodes,
3559 &slots,
3560 );
3561 assert_eq!(html, r#"<input type="email" class="form-input"></input>"#);
3562 }
3563
3564 #[test]
3567 fn dyn_attr_on_void_tag() {
3568 let mut opcodes = Vec::new();
3572 opcodes.extend_from_slice(&encode_void_tag(0, &[]));
3573 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
3574
3575 let mut slots = SlotData::new(1);
3576 slots.set(0, SlotValue::Text("checkbox".to_string()));
3577
3578 let html = walk_with_slots(
3579 &["input", "type", "slot_type"],
3580 &[(0, 2, 0x01, 0x01, b"text")],
3581 &opcodes,
3582 &slots,
3583 );
3584 assert_eq!(html, r#"<input type="checkbox">"#);
3585 }
3586
3587 #[test]
3590 fn walk_island_basic() {
3591 let pre_island = encode_open_tag(1, &[]); let island_start = encode_island_start(0);
3606 let open_p = encode_open_tag(2, &[]); let text_hello = encode_text(3);
3608 let close_p = encode_close_tag(2);
3609 let island_end = encode_island_end(0);
3610 let close_div = encode_close_tag(1);
3611
3612 let byte_offset = pre_island.len() as u32; let mut opcodes = Vec::new();
3615 opcodes.extend_from_slice(&pre_island);
3616 opcodes.extend_from_slice(&island_start);
3617 opcodes.extend_from_slice(&open_p);
3618 opcodes.extend_from_slice(&text_hello);
3619 opcodes.extend_from_slice(&close_p);
3620 opcodes.extend_from_slice(&island_end);
3621 opcodes.extend_from_slice(&close_div);
3622
3623 let data = build_minimal_ir(
3624 &["MyIsland", "div", "p", "hello"],
3625 &[],
3626 &opcodes,
3627 &[(0, 0x01, 0x01, 0, byte_offset, &[])], );
3629 let module = IrModule::parse(&data).unwrap();
3630 let slots = SlotData::new(0);
3631
3632 let html = walk_island(&module, &slots, 0).unwrap();
3633 assert_eq!(
3637 html,
3638 "<p data-forma-island=\"0\" data-forma-component=\"MyIsland\" \
3639 data-forma-status=\"pending\" data-forma-hydrate=\"load\">hello</p>"
3640 );
3641 assert!(!html.contains("<!--f:i0-->"));
3644 assert!(!html.contains("<!--/f:i0-->"));
3645 }
3646
3647 #[test]
3648 fn walk_island_attrs_match_walk_to_html() {
3649 let island_start = encode_island_start(0);
3652 let open_div = encode_open_tag(1, &[]);
3653 let dyn_text = encode_dyn_text(0, 0);
3654 let close_div = encode_close_tag(1);
3655 let island_end = encode_island_end(0);
3656
3657 let mut opcodes = Vec::new();
3658 opcodes.extend_from_slice(&island_start);
3659 opcodes.extend_from_slice(&open_div);
3660 opcodes.extend_from_slice(&dyn_text);
3661 opcodes.extend_from_slice(&close_div);
3662 opcodes.extend_from_slice(&island_end);
3663
3664 let data = build_minimal_ir(
3665 &["IslandComp", "div", "greeting"],
3666 &[(0, 2, 0x01, 0x00, &[])],
3667 &opcodes,
3668 &[(0, 0x02, 0x01, 0, 0, &[0])], );
3670 let module = IrModule::parse(&data).unwrap();
3671 let mut slots = SlotData::new(1);
3672 slots.set(0, SlotValue::Text("World".to_string()));
3673
3674 let page = walk_to_html(&module, &slots).unwrap();
3675 let fragment = walk_island(&module, &slots, 0).unwrap();
3676
3677 for attr in [
3678 "data-forma-island=\"0\"",
3679 "data-forma-component=\"IslandComp\"",
3680 "data-forma-status=\"pending\"",
3681 "data-forma-hydrate=\"visible\"",
3682 "data-forma-props=",
3683 ] {
3684 assert!(page.contains(attr), "page missing {attr}: {page}");
3685 assert!(
3686 fragment.contains(attr),
3687 "fragment missing {attr}: {fragment}"
3688 );
3689 }
3690 }
3691
3692 #[test]
3693 fn walk_island_script_tag_mode_inlines_props_in_fragment() {
3694 let mut opcodes = Vec::new();
3700 opcodes.extend_from_slice(&encode_island_start(0));
3701 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3702 opcodes.extend_from_slice(&encode_close_tag(1));
3703 opcodes.extend_from_slice(&encode_island_end(0));
3704
3705 let data = build_minimal_ir(
3706 &["IslandComp", "div", "greeting"],
3707 &[(0, 2, 0x01, 0x00, &[])],
3708 &opcodes,
3709 &[(0, 0x01, 0x02, 0, 0, &[0])], );
3711 let module = IrModule::parse(&data).unwrap();
3712 let mut slots = SlotData::new(1);
3713 slots.set(0, SlotValue::Text("World".to_string()));
3714
3715 let fragment = walk_island(&module, &slots, 0).unwrap();
3716 assert!(
3717 fragment.contains("data-forma-props="),
3718 "ScriptTag island fragment must inline its props: {fragment}"
3719 );
3720 assert!(
3721 !fragment.contains("__forma_islands"),
3722 "fragment must not emit a second islands block: {fragment}"
3723 );
3724 }
3725
3726 #[test]
3727 fn walk_island_nested_script_tag_island_keeps_its_props() {
3728 let mut opcodes = encode_island_start(0);
3732 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3733 let inner_offset = opcodes.len() as u32;
3734 opcodes.extend_from_slice(&encode_island_start(1));
3735 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3736 opcodes.extend_from_slice(&encode_close_tag(1));
3737 opcodes.extend_from_slice(&encode_island_end(1));
3738 opcodes.extend_from_slice(&encode_close_tag(1));
3739 opcodes.extend_from_slice(&encode_island_end(0));
3740
3741 let data = build_minimal_ir(
3742 &["Outer", "div", "greeting", "Inner"],
3743 &[(0, 2, 0x01, 0x00, &[])],
3744 &opcodes,
3745 &[
3746 (0, 0x01, 0x01, 0, 0, &[]),
3747 (1, 0x01, 0x02, 3, inner_offset, &[0]), ],
3749 );
3750 let module = IrModule::parse(&data).unwrap();
3751 let mut slots = SlotData::new(1);
3752 slots.set(0, SlotValue::Text("World".into()));
3753
3754 let fragment = walk_island(&module, &slots, 0).unwrap();
3755 assert!(
3756 fragment.contains("data-forma-component=\"Inner\""),
3757 "nested island must render: {fragment}"
3758 );
3759 assert!(
3760 fragment.contains("data-forma-props="),
3761 "nested ScriptTag island must keep its props inline: {fragment}"
3762 );
3763 assert!(!fragment.contains("__forma_islands"), "{fragment}");
3764 }
3765
3766 #[test]
3767 fn walk_island_not_found() {
3768 let opcodes = encode_text(0);
3770 let data = build_minimal_ir(&["Hello"], &[], &opcodes, &[]);
3771 let module = IrModule::parse(&data).unwrap();
3772 let slots = SlotData::new(0);
3773
3774 let result = walk_island(&module, &slots, 5);
3775 assert!(result.is_err());
3776 match result.unwrap_err() {
3777 IrError::IslandNotFound(5) => {} other => panic!("expected IslandNotFound(5), got {other:?}"),
3779 }
3780 }
3781
3782 #[test]
3783 fn walk_island_with_dyn_text() {
3784 let island_start = encode_island_start(0);
3789 let open_div = encode_open_tag(1, &[]); let dyn_text = encode_dyn_text(0, 0); let close_div = encode_close_tag(1);
3792 let island_end = encode_island_end(0);
3793
3794 let byte_offset = 0u32; let mut opcodes = Vec::new();
3797 opcodes.extend_from_slice(&island_start);
3798 opcodes.extend_from_slice(&open_div);
3799 opcodes.extend_from_slice(&dyn_text);
3800 opcodes.extend_from_slice(&close_div);
3801 opcodes.extend_from_slice(&island_end);
3802
3803 let data = build_minimal_ir(
3804 &["IslandComp", "div", "greeting"],
3805 &[(0, 2, 0x01, 0x00, &[])], &opcodes,
3807 &[(0, 0x01, 0x01, 0, byte_offset, &[0])], );
3809 let module = IrModule::parse(&data).unwrap();
3810
3811 let mut slots = SlotData::new(1);
3812 slots.set(0, SlotValue::Text("World".to_string()));
3813
3814 let html = walk_island(&module, &slots, 0).unwrap();
3815 assert_eq!(
3817 html,
3818 "<div data-forma-island=\"0\" data-forma-component=\"IslandComp\" \
3819 data-forma-status=\"pending\" data-forma-hydrate=\"load\" \
3820 data-forma-props=\"{"greeting":"World"}\">\
3821 <!--f:t0-->World<!--/f:t0--></div>"
3822 );
3823 }
3824
3825 #[test]
3826 fn walk_island_byte_offset_out_of_bounds() {
3827 let opcodes = encode_text(0); let data = build_minimal_ir(
3835 &["BadIsland", "hello"],
3836 &[],
3837 &opcodes,
3838 &[(0, 0x01, 0x01, 0, 9999, &[])], );
3840 let err = IrModule::parse(&data).unwrap_err();
3841 assert_eq!(
3842 err,
3843 IrError::IslandOffsetMismatch {
3844 island_id: 0,
3845 byte_offset: 9999
3846 }
3847 );
3848 }
3849
3850 fn encode_prop(src_slot_id: u16, prop_str_idx: u32, target_slot_id: u16) -> Vec<u8> {
3854 let mut buf = vec![0x12]; buf.extend_from_slice(&src_slot_id.to_le_bytes());
3856 buf.extend_from_slice(&prop_str_idx.to_le_bytes());
3857 buf.extend_from_slice(&target_slot_id.to_le_bytes());
3858 buf
3859 }
3860
3861 #[test]
3864 fn walk_prop_in_list_body() {
3865 let mut body = Vec::new();
3872 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);
3875
3876 let mut slots = SlotData::new(3);
3877 slots.set(
3878 0,
3879 SlotValue::array(vec![
3880 SlotValue::object(vec![(
3881 "name".to_string(),
3882 SlotValue::Text("Alice".to_string()),
3883 )]),
3884 SlotValue::object(vec![(
3885 "name".to_string(),
3886 SlotValue::Text("Bob".to_string()),
3887 )]),
3888 ]),
3889 );
3890
3891 let html = walk_with_slots(
3892 &["items", "item", "name"],
3893 &[
3894 (0, 0, 0x04, 0x00, &[]), (1, 1, 0x05, 0x00, &[]), (2, 2, 0x01, 0x00, &[]), ],
3898 &opcodes,
3899 &slots,
3900 );
3901 assert!(html.contains("Alice"), "should contain Alice, got: {html}");
3902 assert!(html.contains("Bob"), "should contain Bob, got: {html}");
3903 }
3904
3905 #[test]
3908 fn walk_prop_multiple_properties() {
3909 let mut body = Vec::new();
3916 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);
3927
3928 let mut slots = SlotData::new(4);
3929 slots.set(
3930 0,
3931 SlotValue::array(vec![
3932 SlotValue::object(vec![
3933 ("name".to_string(), SlotValue::Text("Alice".to_string())),
3934 (
3935 "email".to_string(),
3936 SlotValue::Text("alice@test.com".to_string()),
3937 ),
3938 ]),
3939 SlotValue::object(vec![
3940 ("name".to_string(), SlotValue::Text("Bob".to_string())),
3941 (
3942 "email".to_string(),
3943 SlotValue::Text("bob@test.com".to_string()),
3944 ),
3945 ]),
3946 ]),
3947 );
3948
3949 let html = walk_with_slots(
3950 &["rows", "row", "name", "email", "tr", "td"],
3951 &[
3952 (0, 0, 0x04, 0x00, &[]),
3953 (1, 1, 0x05, 0x00, &[]),
3954 (2, 2, 0x01, 0x00, &[]),
3955 (3, 3, 0x01, 0x00, &[]),
3956 ],
3957 &opcodes,
3958 &slots,
3959 );
3960 assert!(
3961 html.contains("<td><!--f:t0-->Alice<!--/f:t0--></td>"),
3962 "should contain Alice td, got: {html}"
3963 );
3964 assert!(
3965 html.contains("<td><!--f:t1-->alice@test.com<!--/f:t1--></td>"),
3966 "should contain alice email, got: {html}"
3967 );
3968 assert!(
3969 html.contains("<td><!--f:t0-->Bob<!--/f:t0--></td>"),
3970 "should contain Bob td, got: {html}"
3971 );
3972 assert!(
3973 html.contains("<td><!--f:t1-->bob@test.com<!--/f:t1--></td>"),
3974 "should contain bob email, got: {html}"
3975 );
3976 }
3977
3978 #[test]
3981 fn walk_prop_on_non_object() {
3982 let mut opcodes = Vec::new();
3985 opcodes.extend_from_slice(&encode_prop(0, 1, 1));
3986 opcodes.extend_from_slice(&encode_dyn_text(1, 0));
3987
3988 let mut slots = SlotData::new(2);
3989 slots.set(0, SlotValue::Text("hello".to_string()));
3990
3991 let html = walk_with_slots(
3992 &["src", "name"],
3993 &[(0, 0, 0x01, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
3994 &opcodes,
3995 &slots,
3996 );
3997 assert!(
3999 html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4000 "non-object PROP should produce empty text, got: {html}"
4001 );
4002 }
4003
4004 #[test]
4007 fn walk_prop_missing_property() {
4008 let mut opcodes = Vec::new();
4011 opcodes.extend_from_slice(&encode_prop(0, 2, 1));
4012 opcodes.extend_from_slice(&encode_dyn_text(1, 0));
4013
4014 let mut slots = SlotData::new(2);
4015 slots.set(
4016 0,
4017 SlotValue::object(vec![(
4018 "name".to_string(),
4019 SlotValue::Text("Alice".to_string()),
4020 )]),
4021 );
4022
4023 let html = walk_with_slots(
4024 &["src", "target", "missing"],
4025 &[(0, 0, 0x05, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4026 &opcodes,
4027 &slots,
4028 );
4029 assert!(
4030 html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4031 "missing property should produce empty text, got: {html}"
4032 );
4033 }
4034
4035 #[test]
4038 fn walk_nested_show_if_within_limit() {
4039 let inner = encode_text(5);
4043 let level4 = encode_show_if(4, &inner, &[]);
4044 let level3 = encode_show_if(3, &level4, &[]);
4045 let level2 = encode_show_if(2, &level3, &[]);
4046 let level1 = encode_show_if(1, &level2, &[]);
4047 let level0 = encode_show_if(0, &level1, &[]);
4048
4049 let mut slots = SlotData::new(5);
4050 for i in 0..5 {
4051 slots.set(i, SlotValue::Bool(true));
4052 }
4053
4054 let html = walk_with_slots(
4055 &["s0", "s1", "s2", "s3", "s4", "deep"],
4056 &[
4057 (0, 0, 0x02, 0x00, &[]),
4058 (1, 1, 0x02, 0x00, &[]),
4059 (2, 2, 0x02, 0x00, &[]),
4060 (3, 3, 0x02, 0x00, &[]),
4061 (4, 4, 0x02, 0x00, &[]),
4062 ],
4063 &level0,
4064 &slots,
4065 );
4066 assert!(
4067 html.contains("deep"),
4068 "5 levels of nesting should succeed, got: {html}"
4069 );
4070 }
4071
4072 fn script_tag_props_html(payload: &str) -> (String, String) {
4075 let mut opcodes = Vec::new();
4078 opcodes.extend_from_slice(&encode_island_start(0));
4079 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4080 opcodes.extend_from_slice(&encode_close_tag(0));
4081 opcodes.extend_from_slice(&encode_island_end(0));
4082
4083 let mut slots = SlotData::new(1);
4084 slots.set(0, SlotValue::Text(payload.to_string()));
4085
4086 let html = walk_with_islands(
4087 &["div", "Comp", "payload"],
4088 &[(0, 2, 0x01, 0x00, &[])],
4089 &[(0, 0x01, 0x02, 1, 0, &[0])],
4090 &opcodes,
4091 &slots,
4092 );
4093
4094 const OPEN: &str = "type=\"application/json\">";
4095 let start = html.find(OPEN).expect("islands script block") + OPEN.len();
4096 let end = html[start..].find("</script>").expect("script close") + start;
4097 let body = html[start..end].to_string();
4098 (html, body)
4099 }
4100
4101 #[test]
4102 fn walk_script_tag_props_body_has_no_raw_angle_brackets() {
4103 let (_html, body) = script_tag_props_html("</script>alert(1)");
4108 assert!(
4109 !body.contains('<') && !body.contains('>'),
4110 "islands block must contain no raw angle brackets, got: {body}"
4111 );
4112 assert!(body.contains("\\u003c/script\\u003e"), "got: {body}");
4113
4114 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4118 assert_eq!(parsed["0"]["payload"], "</script>alert(1)");
4119 }
4120
4121 #[test]
4122 fn walk_script_tag_props_escapes_comment_script_breakout() {
4123 let payload = "<!--<script>PAYLOAD";
4129 let (html, body) = script_tag_props_html(payload);
4130
4131 assert!(
4132 !html.contains("<!--<script"),
4133 "must not emit the script-data-double-escape trigger, got: {html}"
4134 );
4135 assert!(!body.contains('<') && !body.contains('>'), "got: {body}");
4136
4137 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4138 assert_eq!(parsed["0"]["payload"], payload);
4139 }
4140
4141 #[test]
4142 #[ignore = "requires admin/dist IR files — run in monorepo or after `npm run build`"]
4143 fn walk_real_benchmark_ir() {
4144 let dist_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../admin/dist");
4146 let ir_path = std::fs::read_dir(&dist_dir)
4147 .expect("admin/dist must exist")
4148 .filter_map(|e| e.ok())
4149 .find(|e| {
4150 let name = e.file_name();
4151 let s = name.to_string_lossy();
4152 s.starts_with("platform-benchmark.") && s.ends_with(".ir")
4153 })
4154 .map(|e| e.path())
4155 .expect("platform-benchmark.*.ir must exist in admin/dist");
4156 let data = std::fs::read(&ir_path).expect("benchmark IR file must exist");
4157 let module = IrModule::parse(&data).expect("IR must parse");
4158
4159 let slots = SlotData::new_from_defaults(&module.slots);
4161 let html = walk_to_html(&module, &slots).expect("walk must succeed");
4162
4163 assert!(html.contains("<!--f:i0-->"), "missing island 0 start");
4165 assert!(html.contains("<!--/f:i0-->"), "missing island 0 end");
4166 assert!(
4167 html.contains("data-forma-island=\"0\""),
4168 "missing island 0 attr"
4169 );
4170
4171 assert!(
4174 html.contains("Search"),
4175 "FilterBar must contain 'Search' label text in SSR"
4176 );
4177 assert!(
4178 html.contains("filter-group"),
4179 "FilterBar must contain filter-group divs in SSR"
4180 );
4181 assert!(
4182 html.contains("Sort by"),
4183 "FilterBar must contain 'Sort by' label text in SSR"
4184 );
4185
4186 assert!(
4188 html.contains("Performance"),
4189 "PerfPanel must contain 'Performance' heading in SSR"
4190 );
4191
4192 assert!(
4195 html.contains("benchmark-data-table"),
4196 "BenchmarkDataTable root class must exist"
4197 );
4198
4199 let start = html
4203 .find("<!--f:i0-->")
4204 .expect("FilterBar island start marker");
4205 let end = html
4206 .find("<!--/f:i0-->")
4207 .expect("FilterBar island end marker");
4208 assert!(end > start, "island end marker must follow its start");
4209 }
4210
4211 #[test]
4214 fn walk_output_limit_trips_inside_one_opcode() {
4215 let big = "&".repeat(4096); let attrs: Vec<(u32, u32)> = (0..200).map(|_| (1u32, 2u32)).collect();
4221 let opcodes = encode_open_tag(0, &attrs);
4222
4223 let data = build_minimal_ir(&["div", "data-x", &big], &[], &opcodes, &[]);
4224 let module = IrModule::parse(&data).unwrap();
4225 let slots = SlotData::new(0);
4226
4227 let limits = WalkLimits {
4228 max_output_bytes: 64 * 1024,
4229 ..WalkLimits::default()
4230 };
4231 let err = walk_to_html_with_limits(&module, &slots, limits).unwrap_err();
4232 assert_eq!(err, IrError::OutputLimitExceeded { limit: 64 * 1024 });
4233
4234 let ok = WalkLimits {
4236 max_output_bytes: 64 * 1024 * 1024,
4237 ..WalkLimits::default()
4238 };
4239 assert!(walk_to_html_with_limits(&module, &slots, ok).is_ok());
4240 }
4241
4242 #[test]
4243 fn walk_output_limit_trips_on_a_single_dyn_text_value() {
4244 let opcodes = encode_dyn_text(0, 0);
4246 let data = build_minimal_ir(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &[]);
4247 let module = IrModule::parse(&data).unwrap();
4248
4249 let mut slots = SlotData::new(1);
4250 slots.set(0, SlotValue::Text("x".repeat(100_000)));
4251
4252 let limits = WalkLimits {
4253 max_output_bytes: 4096,
4254 ..WalkLimits::default()
4255 };
4256 assert!(matches!(
4257 walk_to_html_with_limits(&module, &slots, limits),
4258 Err(IrError::OutputLimitExceeded { .. })
4259 ));
4260 }
4261
4262 #[test]
4265 fn walk_list_budget_exceeded() {
4266 let body = encode_text(2);
4269 let inner = encode_list(0, 1, &body);
4270 let opcodes = encode_list(0, 1, &inner);
4271
4272 let data = build_minimal_ir(
4273 &["items", "item", "x"],
4274 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4275 &opcodes,
4276 &[],
4277 );
4278 let module = IrModule::parse(&data).unwrap();
4279
4280 let mut slots = SlotData::new(2);
4281 slots.set(
4282 0,
4283 SlotValue::array((0..300).map(|_| SlotValue::Text("a".into())).collect()),
4284 );
4285
4286 let err = walk_to_html(&module, &slots).unwrap_err();
4287 assert_eq!(
4288 err,
4289 IrError::ListBudgetExceeded {
4290 max: DEFAULT_MAX_LIST_ITERATIONS
4291 }
4292 );
4293 assert_ne!(
4297 err,
4298 IrError::ListDepthExceeded {
4299 max: MAX_LIST_DEPTH
4300 }
4301 );
4302 }
4303
4304 fn patch_u32(buf: &mut [u8], at: usize, value: u32) {
4308 buf[at..at + 4].copy_from_slice(&value.to_le_bytes());
4309 }
4310
4311 #[test]
4312 fn parse_rejects_show_if_then_len_past_the_stream() {
4313 let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
4315 patch_u32(&mut opcodes, 3, 0xFFFF_FF00); let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
4317 assert!(matches!(
4318 IrModule::parse(&data),
4319 Err(IrError::MalformedControlFlow { .. })
4320 ));
4321 }
4322
4323 #[test]
4324 fn parse_rejects_show_if_marker_byte_that_is_not_show_else() {
4325 let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
4328 patch_u32(&mut opcodes, 3, 4); let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
4330 assert!(matches!(
4331 IrModule::parse(&data),
4332 Err(IrError::MalformedControlFlow { .. })
4333 ));
4334 }
4335
4336 #[test]
4337 fn parse_rejects_list_body_len_past_the_stream() {
4338 let mut opcodes = encode_list(0, 1, &encode_text(2));
4340 patch_u32(&mut opcodes, 5, 0xFFFF_FF00);
4341 let data = build_minimal_ir(
4342 &["items", "item", "x"],
4343 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4344 &opcodes,
4345 &[],
4346 );
4347 assert!(matches!(
4348 IrModule::parse(&data),
4349 Err(IrError::MalformedControlFlow { .. })
4350 ));
4351 }
4352
4353 #[test]
4354 fn parse_rejects_switch_case_body_len_past_the_stream() {
4355 let mut opcodes = encode_switch(0, &[(1, &encode_text(2)), (3, &encode_text(2))]);
4358 patch_u32(&mut opcodes, 9, 0xFFFF_FF00);
4360 let data = build_minimal_ir(
4361 &["mode", "a", "x", "b"],
4362 &[(0, 0, 0x01, 0x00, &[])],
4363 &opcodes,
4364 &[],
4365 );
4366 assert!(matches!(
4367 IrModule::parse(&data),
4368 Err(IrError::MalformedControlFlow { .. })
4369 ));
4370 }
4371
4372 #[test]
4373 fn parse_rejects_fallback_skip_past_the_stream() {
4374 let mut opcodes = encode_try(&encode_text(0), &encode_text(0));
4376 patch_u32(&mut opcodes, 1, 0xFFFF_FF00);
4377 let data = build_minimal_ir(&["hi"], &[], &opcodes, &[]);
4378 assert!(matches!(
4379 IrModule::parse(&data),
4380 Err(IrError::MalformedControlFlow { .. })
4381 ));
4382 }
4383
4384 #[test]
4385 fn parse_accepts_zero_length_else_branch_known_residual() {
4386 let opcodes = encode_show_if(0, &encode_text(1), &[]);
4392 let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
4393 assert!(IrModule::parse(&data).is_ok());
4394 }
4395
4396 #[test]
4399 fn walk_stray_fallback_in_a_nested_range_is_rejected_at_load() {
4400 let then_ops = vec![0x0E]; let mut main_ops = encode_show_if(0, &then_ops, &[]);
4409 main_ops.extend_from_slice(&encode_text(1));
4410 let opcodes = encode_try(&main_ops, &encode_text(2));
4411
4412 let data = build_minimal_ir(
4413 &["flag", "main", "fb"],
4414 &[(0, 0, 0x02, 0x00, &[])],
4415 &opcodes,
4416 &[],
4417 );
4418 assert!(matches!(
4419 IrModule::parse(&data).unwrap_err(),
4420 IrError::MalformedControlFlow { .. }
4421 ));
4422 }
4423
4424 #[test]
4425 fn walk_try_without_a_fallback_is_rejected_at_load() {
4426 let mut opcodes = vec![0x0D]; opcodes.extend_from_slice(&4u32.to_le_bytes()); opcodes.extend_from_slice(&encode_text(0));
4431
4432 let data = build_minimal_ir(&["main"], &[], &opcodes, &[]);
4433 assert!(matches!(
4434 IrModule::parse(&data).unwrap_err(),
4435 IrError::MalformedControlFlow { .. }
4436 ));
4437 }
4438
4439 #[test]
4442 fn walk_island_attrs_survive_an_empty_show_if_before_the_root() {
4443 let mut opcodes = encode_island_start(0);
4448 opcodes.extend_from_slice(&encode_show_if(0, &[], &[]));
4449 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4450 opcodes.extend_from_slice(&encode_close_tag(1));
4451 opcodes.extend_from_slice(&encode_island_end(0));
4452
4453 let slots = SlotData::new(1);
4454 let html = walk_with_islands(
4455 &["Comp", "div", "flag"],
4456 &[(0, 2, 0x02, 0x00, &[])],
4457 &[(0, 0x01, 0x01, 0, 0, &[])],
4458 &opcodes,
4459 &slots,
4460 );
4461 assert!(
4462 html.contains("<div data-forma-island=\"0\""),
4463 "island root must keep its attributes, got: {html}"
4464 );
4465 }
4466
4467 #[test]
4468 fn walk_list_key_survives_an_empty_show_if_before_the_item_element() {
4469 let mut body = encode_list_item_key(3);
4470 body.extend_from_slice(&encode_show_if(1, &[], &[]));
4471 body.extend_from_slice(&encode_open_tag(2, &[]));
4472 body.extend_from_slice(&encode_close_tag(2));
4473 let opcodes = encode_list(0, 1, &body);
4474
4475 let mut slots = SlotData::new(2);
4476 slots.set(
4477 0,
4478 SlotValue::array(vec![
4479 SlotValue::Text("a".into()),
4480 SlotValue::Text("b".into()),
4481 ]),
4482 );
4483
4484 let html = walk_with_slots(
4485 &["items", "item", "li", "k"],
4486 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4487 &opcodes,
4488 &slots,
4489 );
4490 assert_eq!(
4491 html.matches("data-forma-key=\"k\"").count(),
4492 2,
4493 "every item element must keep its key, got: {html}"
4494 );
4495 }
4496
4497 #[test]
4498 fn walk_island_attrs_do_not_leak_past_the_island_end() {
4499 let mut opcodes = encode_island_start(0);
4503 opcodes.extend_from_slice(&encode_island_end(0));
4504 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4505 opcodes.extend_from_slice(&encode_close_tag(1));
4506
4507 let slots = SlotData::new(0);
4508 let html = walk_with_islands(
4509 &["Comp", "div"],
4510 &[],
4511 &[(0, 0x01, 0x01, 0, 0, &[])],
4512 &opcodes,
4513 &slots,
4514 );
4515 assert!(
4516 !html.contains("data-forma-island"),
4517 "island attributes must not leak onto an element outside the island, got: {html}"
4518 );
4519 }
4520
4521 #[test]
4522 fn walk_list_key_does_not_leak_past_the_list() {
4523 let body = encode_list_item_key(2); let mut opcodes = encode_list(0, 1, &body);
4525 opcodes.extend_from_slice(&encode_open_tag(3, &[]));
4526 opcodes.extend_from_slice(&encode_close_tag(3));
4527
4528 let mut slots = SlotData::new(2);
4529 slots.set(0, SlotValue::array(vec![SlotValue::Text("a".into())]));
4530
4531 let html = walk_with_slots(
4532 &["items", "item", "k", "div"],
4533 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4534 &opcodes,
4535 &slots,
4536 );
4537 assert!(
4538 !html.contains("data-forma-key"),
4539 "list key must not leak onto an element after the list, got: {html}"
4540 );
4541 }
4542
4543 #[test]
4546 fn walk_rejects_hostile_tag_name() {
4547 let opcodes = encode_open_tag(0, &[]);
4550 let data = build_minimal_ir(&["div onload=\"alert(1)\""], &[], &opcodes, &[]);
4551 let module = IrModule::parse(&data).unwrap();
4552 let slots = SlotData::new(0);
4553 assert!(matches!(
4554 walk_to_html(&module, &slots),
4555 Err(IrError::InvalidName(_))
4556 ));
4557 }
4558
4559 #[test]
4560 fn walk_rejects_hostile_close_tag_name() {
4561 let mut opcodes = encode_open_tag(0, &[]);
4562 opcodes.extend_from_slice(&encode_close_tag(1));
4563 let data = build_minimal_ir(&["div", "div><script"], &[], &opcodes, &[]);
4564 let module = IrModule::parse(&data).unwrap();
4565 let slots = SlotData::new(0);
4566 assert!(matches!(
4567 walk_to_html(&module, &slots),
4568 Err(IrError::InvalidName(_))
4569 ));
4570 }
4571
4572 #[test]
4573 fn walk_drops_hostile_attribute_names() {
4574 let opcodes = encode_open_tag(0, &[(1, 2), (3, 2)]);
4577 let data = build_minimal_ir(&["div", "x\"><script", "v", "ok-attr"], &[], &opcodes, &[]);
4578 let module = IrModule::parse(&data).unwrap();
4579 let slots = SlotData::new(0);
4580 let html = walk_to_html(&module, &slots).unwrap();
4581 assert_eq!(html, "<div ok-attr=\"v\">");
4582 }
4583
4584 #[test]
4585 fn walk_drops_hostile_boolean_attribute_name() {
4586 let opcodes = encode_open_tag(0, &[(1, 2)]);
4588 let data = build_minimal_ir(&["div", "y\"><script", ""], &[], &opcodes, &[]);
4589 let module = IrModule::parse(&data).unwrap();
4590 let slots = SlotData::new(0);
4591 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
4592 }
4593
4594 #[test]
4595 fn walk_drops_hostile_dyn_attr_name() {
4596 let mut opcodes = encode_open_tag(0, &[]);
4597 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4598 let data = build_minimal_ir(
4599 &["div", "z\"><script", "val"],
4600 &[(0, 2, 0x01, 0x00, &[])],
4601 &opcodes,
4602 &[],
4603 );
4604 let module = IrModule::parse(&data).unwrap();
4605 let mut slots = SlotData::new(1);
4606 slots.set(0, SlotValue::Text("x".into()));
4607 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
4608 }
4609
4610 #[test]
4613 fn walk_drops_javascript_url_in_dyn_attr() {
4614 let mut opcodes = encode_open_tag(0, &[]);
4615 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4616 let data = build_minimal_ir(
4617 &["a", "href", "url"],
4618 &[(0, 2, 0x01, 0x00, &[])],
4619 &opcodes,
4620 &[],
4621 );
4622 let module = IrModule::parse(&data).unwrap();
4623
4624 for payload in [
4627 "javascript:alert(1)",
4628 "JaVaScRiPt:alert(1)",
4629 "java\tscript:alert(1)",
4630 " javascript:alert(1)",
4631 "vbscript:msgbox(1)",
4632 "data:text/html,<script>alert(1)</script>",
4633 ] {
4634 let mut slots = SlotData::new(1);
4635 slots.set(0, SlotValue::Text(payload.to_string()));
4636 let html = walk_to_html(&module, &slots).unwrap();
4637 assert_eq!(html, "<a>", "payload {payload:?} was not dropped");
4640 }
4641 }
4642
4643 #[test]
4644 fn walk_keeps_safe_urls_and_data_images() {
4645 let mut opcodes = encode_open_tag(0, &[]);
4646 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4647 let data = build_minimal_ir(
4648 &["img", "src", "url"],
4649 &[(0, 2, 0x01, 0x00, &[])],
4650 &opcodes,
4651 &[],
4652 );
4653 let module = IrModule::parse(&data).unwrap();
4654
4655 for payload in [
4656 "/about",
4657 "https://example.com/x?a=1",
4658 "data:image/png;base64,AAAA",
4659 "data:image/svg+xml,%3Csvg%3E%3C/svg%3E",
4663 ] {
4664 let mut slots = SlotData::new(1);
4665 slots.set(0, SlotValue::Text(payload.to_string()));
4666 let html = walk_to_html(&module, &slots).unwrap();
4667 assert!(html.contains("src="), "payload {payload:?} was dropped");
4668 }
4669 }
4670
4671 #[test]
4672 fn walk_blocks_svg_data_url_outside_image_context() {
4673 let mut opcodes = encode_open_tag(0, &[]);
4674 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4675 let data = build_minimal_ir(
4676 &["a", "href", "url"],
4677 &[(0, 2, 0x01, 0x00, &[])],
4678 &opcodes,
4679 &[],
4680 );
4681 let module = IrModule::parse(&data).unwrap();
4682 let mut slots = SlotData::new(1);
4683 slots.set(
4684 0,
4685 SlotValue::Text("data:image/svg+xml,%3Csvg onload=alert(1)%3E".into()),
4686 );
4687 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<a>");
4690 }
4691
4692 #[test]
4693 fn walk_drops_event_handler_attributes() {
4694 let opcodes = encode_open_tag(0, &[(1, 2)]);
4696 let data = build_minimal_ir(&["div", "onmouseover", "alert(1)"], &[], &opcodes, &[]);
4697 let module = IrModule::parse(&data).unwrap();
4698 assert_eq!(
4699 walk_to_html(&module, &SlotData::new(0)).unwrap(),
4700 "<div>",
4701 "static on* attribute must be dropped"
4702 );
4703
4704 let mut opcodes = encode_open_tag(0, &[]);
4706 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4707 let data = build_minimal_ir(
4708 &["div", "ONCLICK", "h"],
4709 &[(0, 2, 0x01, 0x00, &[])],
4710 &opcodes,
4711 &[],
4712 );
4713 let module = IrModule::parse(&data).unwrap();
4714 let mut slots = SlotData::new(1);
4715 slots.set(0, SlotValue::Text("alert(1)".into()));
4716 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
4717 }
4718
4719 #[test]
4720 fn walk_drops_javascript_url_in_static_attr() {
4721 let opcodes = encode_open_tag(0, &[(1, 2)]);
4725 let data = build_minimal_ir(&["a", "href", "javascript:alert(1)"], &[], &opcodes, &[]);
4726 let module = IrModule::parse(&data).unwrap();
4727 assert_eq!(walk_to_html(&module, &SlotData::new(0)).unwrap(), "<a>");
4728 }
4729
4730 #[test]
4731 fn walk_comment_cannot_self_close() {
4732 for text in ["><img src=x onerror=alert(1)>", "-><img src=x>"] {
4735 let opcodes = encode_comment(0);
4736 let data = build_minimal_ir(&[text], &[], &opcodes, &[]);
4737 let module = IrModule::parse(&data).unwrap();
4738 let html = walk_to_html(&module, &SlotData::new(0)).unwrap();
4739 assert!(
4740 html.starts_with("<!-- "),
4741 "comment must not start with a closing sequence, got: {html}"
4742 );
4743 }
4744 }
4745
4746 #[test]
4749 fn walk_switch_matches_on_js_number_text() {
4750 let opcodes = encode_switch(0, &[(1, &encode_text(2)), (3, &encode_text(4))]);
4753 let data = build_minimal_ir(
4754 &["n", "1e+21", "big", "1000", "small"],
4755 &[(0, 0, 0x03, 0x00, &[])],
4756 &opcodes,
4757 &[],
4758 );
4759 let module = IrModule::parse(&data).unwrap();
4760
4761 let mut slots = SlotData::new(1);
4762 slots.set(0, SlotValue::Number(1e21));
4763 assert!(walk_to_html(&module, &slots).unwrap().contains("big"));
4764
4765 let mut slots = SlotData::new(1);
4766 slots.set(0, SlotValue::Number(1000.0));
4767 assert!(walk_to_html(&module, &slots).unwrap().contains("small"));
4768 }
4769}