1#[cfg(feature = "tracing")]
8use crate::format::SlotSource;
9use crate::format::{IrError, IslandTrigger, Opcode, PropsMode};
10use crate::parser::IrModule;
11use crate::slot::{SlotData, SlotValue};
12use std::fmt;
13
14struct IslandPending {
20 id: u16,
21 component_name: String,
22 inline_props: Option<String>,
23 trigger: IslandTrigger,
24}
25
26pub const INLINE_PROPS_MAX_BYTES: usize = 1024;
48
49pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
58
59pub const DEFAULT_MAX_LIST_ITERATIONS: usize = 50_000;
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct WalkLimits {
71 pub max_output_bytes: usize,
73 pub max_list_iterations: usize,
75}
76
77impl Default for WalkLimits {
78 fn default() -> Self {
79 WalkLimits {
80 max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
81 max_list_iterations: DEFAULT_MAX_LIST_ITERATIONS,
82 }
83 }
84}
85
86struct WalkState {
91 list_depth: u8,
93 pending_island: Option<IslandPending>,
95 pending_list_key: Option<String>,
97 script_tag_props: std::collections::BTreeMap<u16, serde_json::Value>,
99 pending_tag_close: bool,
102 pending_tag_name: String,
105 max_output_bytes: usize,
109 list_iterations: usize,
112 max_list_iterations: usize,
114 slot_journal: Vec<(u16, SlotValue)>,
128 force_inline_props: bool,
133 text_only: Option<TextOnlyParent>,
137 pending_control: PendingControl,
140 select: Option<SelectBinding>,
142 option_by_text: Option<OptionByText>,
145 nodes: usize,
147 islands: Vec<u16>,
150 #[cfg(any(test, feature = "__fault-injection"))]
154 fault_armed: bool,
155}
156
157impl WalkState {
158 fn new(limits: WalkLimits) -> Self {
159 WalkState {
160 list_depth: 0,
161 pending_island: None,
162 pending_list_key: None,
163 script_tag_props: std::collections::BTreeMap::new(),
164 pending_tag_close: false,
165 pending_tag_name: String::new(),
166 max_output_bytes: limits.max_output_bytes,
167 list_iterations: 0,
168 max_list_iterations: limits.max_list_iterations,
169 slot_journal: Vec::new(),
170 force_inline_props: false,
171 text_only: None,
172 pending_control: PendingControl::None,
173 select: None,
174 option_by_text: None,
175 nodes: 0,
176 islands: Vec::new(),
177 #[cfg(any(test, feature = "__fault-injection"))]
178 fault_armed: false,
179 }
180 }
181
182 fn journal_write(&mut self, slot_id: u16, previous: SlotValue) {
188 if self.list_depth > 0 {
189 self.slot_journal.push((slot_id, previous));
190 }
191 }
192
193 fn unwind_slot_journal(&mut self, slots: &mut SlotData, mark: usize) {
195 while self.slot_journal.len() > mark {
196 if let Some((slot_id, previous)) = self.slot_journal.pop() {
198 slots.set(slot_id, previous);
199 }
200 }
201 }
202}
203
204#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
215fn check_slot_source(module: &IrModule, slot_id: u16, value: &SlotValue) {
216 #[cfg(feature = "tracing")]
220 if let Some(entry) = module.slot_entry(slot_id) {
221 if entry.source == SlotSource::Server && matches!(value, SlotValue::Null) {
222 tracing::warn!(
223 slot_id = slot_id,
224 "Server-sourced slot has no value at render time — handler may have a bug"
225 );
226 }
227 }
228}
229
230pub fn walk_to_html(module: &IrModule, slots: &SlotData) -> Result<String, IrError> {
240 walk_to_html_with_limits(module, slots, WalkLimits::default())
241}
242
243pub fn walk_to_html_with_limits(
250 module: &IrModule,
251 slots: &SlotData,
252 limits: WalkLimits,
253) -> Result<String, IrError> {
254 let ops = &module.opcodes;
255 let len = ops.len();
256 let mut out = String::with_capacity(initial_capacity(len));
257 let mut state = WalkState::new(limits);
258 let mut slots_mut = slots.clone();
259 walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)?;
260 finish_page(&mut out, &state)?;
261 Ok(out)
262}
263
264fn initial_capacity(opcodes_len: usize) -> usize {
272 opcodes_len.saturating_mul(4).min(1 << 20)
273}
274
275fn finish_page(out: &mut String, state: &WalkState) -> Result<(), IrError> {
277 if !state.script_tag_props.is_empty() {
278 let json =
279 serde_json::to_string(&state.script_tag_props).unwrap_or_else(|_| "{}".to_string());
280 push_checked(
281 out,
282 "<script id=\"__forma_islands\" type=\"application/json\">",
283 state.max_output_bytes,
284 )?;
285 push_json_script_body(out, &json, state.max_output_bytes)?;
286 push_checked(out, "</script>", state.max_output_bytes)?;
287 }
288 Ok(())
289}
290
291#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
293#[non_exhaustive]
294pub struct WalkCounts {
295 pub nodes: usize,
298 pub bytes: usize,
300 pub list_iterations: usize,
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
307#[non_exhaustive]
308pub struct WalkOutput {
309 pub html: String,
311 pub counts: WalkCounts,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq)]
319#[non_exhaustive]
320pub struct WalkFailure {
321 pub error: IrError,
323 pub component_path: Vec<String>,
328 pub counts: WalkCounts,
330}
331
332impl fmt::Display for WalkFailure {
333 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334 write!(f, "{}", self.error)?;
335 if !self.component_path.is_empty() {
336 write!(f, " (in {})", self.component_path.join(" > "))?;
337 }
338 Ok(())
339 }
340}
341
342impl std::error::Error for WalkFailure {
343 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
344 Some(&self.error)
345 }
346}
347
348pub fn walk_to_html_counted(
363 module: &IrModule,
364 slots: &SlotData,
365 limits: WalkLimits,
366) -> Result<WalkOutput, WalkFailure> {
367 let ops = &module.opcodes;
368 let len = ops.len();
369 let mut out = String::with_capacity(initial_capacity(len));
370 let mut state = WalkState::new(limits);
371 #[cfg(any(test, feature = "__fault-injection"))]
372 {
373 state.fault_armed = true;
374 }
375 let mut slots_mut = slots.clone();
376
377 let walked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
381 walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)?;
382 finish_page(&mut out, &state)
383 }));
384 let counts = WalkCounts {
385 nodes: state.nodes,
386 bytes: out.len(),
387 list_iterations: state.list_iterations,
388 };
389 let error = match walked {
390 Ok(Ok(())) => return Ok(WalkOutput { html: out, counts }),
391 Ok(Err(error)) => error,
392 Err(payload) => IrError::Panicked {
393 message: panic_message(payload.as_ref()),
394 },
395 };
396 let component_path = state
397 .islands
398 .iter()
399 .map(|&id| {
400 module
401 .island_entry(id)
402 .and_then(|entry| module.strings.get(entry.name_str_idx).ok())
403 .map_or_else(|| format!("island {id}"), str::to_string)
404 })
405 .collect();
406 Err(WalkFailure {
407 error,
408 component_path,
409 counts,
410 })
411}
412
413fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
416 if let Some(message) = payload.downcast_ref::<&str>() {
417 (*message).to_string()
418 } else if let Some(message) = payload.downcast_ref::<String>() {
419 message.clone()
420 } else {
421 "a panic with no message".to_string()
422 }
423}
424
425fn push_json_script_body(out: &mut String, json: &str, limit: usize) -> Result<(), IrError> {
446 for ch in json.chars() {
447 match ch {
448 '<' => push_checked(out, "\\u003c", limit)?,
449 '>' => push_checked(out, "\\u003e", limit)?,
450 '\u{2028}' => push_checked(out, "\\u2028", limit)?,
451 '\u{2029}' => push_checked(out, "\\u2029", limit)?,
452 _ => {
453 if out.len() >= limit {
454 return Err(IrError::OutputLimitExceeded { limit });
455 }
456 out.push(ch);
457 }
458 }
459 }
460 Ok(())
461}
462
463pub fn walk_island(module: &IrModule, slots: &SlotData, island_id: u16) -> Result<String, IrError> {
487 walk_island_with_limits(module, slots, island_id, WalkLimits::default())
488}
489
490pub fn walk_island_with_limits(
492 module: &IrModule,
493 slots: &SlotData,
494 island_id: u16,
495 limits: WalkLimits,
496) -> Result<String, IrError> {
497 let entry = module
499 .island_entry(island_id)
500 .ok_or(IrError::IslandNotFound(island_id))?;
501
502 let byte_offset = entry.byte_offset as usize;
503 let ops = &module.opcodes;
504
505 if byte_offset >= ops.len() {
508 return Err(IrError::BufferTooShort {
509 expected: byte_offset.saturating_add(1),
510 actual: ops.len(),
511 });
512 }
513
514 let opcode = Opcode::from_byte(ops[byte_offset])?;
516 if opcode != Opcode::IslandStart {
517 return Err(IrError::InvalidOpcode(ops[byte_offset]));
518 }
519
520 let content_start = byte_offset.saturating_add(3);
522 if content_start > ops.len() {
523 return Err(IrError::BufferTooShort {
524 expected: content_start,
525 actual: ops.len(),
526 });
527 }
528
529 let mut out = String::with_capacity(256);
533 let mut state = WalkState::new(limits);
534 state.force_inline_props = true;
537 let mut slots_mut = slots.clone();
538
539 let component_name = module.strings.get(entry.name_str_idx)?.to_string();
540 let inline_props = match entry.props_mode {
541 PropsMode::Inline | PropsMode::ScriptTag => {
544 if entry.slot_ids.is_empty() {
545 None
546 } else {
547 let props = build_island_props(module, &slots_mut, &entry.slot_ids);
548 Some(serde_json::to_string(&props).unwrap_or_else(|_| "{}".to_string()))
549 }
550 }
551 PropsMode::Deferred => None,
552 };
553 state.pending_island = Some(IslandPending {
554 id: island_id,
555 component_name,
556 inline_props,
557 trigger: entry.trigger,
558 });
559
560 walk_range_until_island_end(
562 module,
563 &mut slots_mut,
564 ops,
565 content_start,
566 island_id,
567 &mut out,
568 &mut state,
569 0,
570 )?;
571
572 Ok(out)
573}
574
575const MAX_LIST_DEPTH: u8 = 4;
582
583const MAX_RECURSION_DEPTH: usize = 64;
586
587enum WalkMode {
589 Range { end: usize },
593 UntilIslandEnd { target_island_id: u16 },
596}
597
598#[allow(clippy::too_many_arguments)]
605fn walk_range(
606 module: &IrModule,
607 slots: &mut SlotData,
608 ops: &[u8],
609 start: usize,
610 end: usize,
611 out: &mut String,
612 state: &mut WalkState,
613 depth: usize,
614) -> Result<(), IrError> {
615 walk_range_impl(
616 module,
617 slots,
618 ops,
619 start,
620 WalkMode::Range { end },
621 out,
622 state,
623 depth,
624 )
625}
626
627#[allow(clippy::too_many_arguments)]
634fn walk_range_until_island_end(
635 module: &IrModule,
636 slots: &mut SlotData,
637 ops: &[u8],
638 start: usize,
639 target_island_id: u16,
640 out: &mut String,
641 state: &mut WalkState,
642 depth: usize,
643) -> Result<(), IrError> {
644 walk_range_impl(
645 module,
646 slots,
647 ops,
648 start,
649 WalkMode::UntilIslandEnd { target_island_id },
650 out,
651 state,
652 depth,
653 )
654}
655
656#[allow(clippy::too_many_arguments)]
671fn walk_range_impl(
672 module: &IrModule,
673 slots: &mut SlotData,
674 ops: &[u8],
675 start: usize,
676 mode: WalkMode,
677 out: &mut String,
678 state: &mut WalkState,
679 depth: usize,
680) -> Result<(), IrError> {
681 if depth > MAX_RECURSION_DEPTH {
682 return Err(IrError::RecursionLimitExceeded);
683 }
684
685 let strings = &module.strings;
686 let mut pos = start;
687 let limit = state.max_output_bytes;
688 let mut fallback_stack: Vec<usize> = Vec::new();
696
697 let loop_bound = match mode {
701 WalkMode::Range { end } => end,
702 WalkMode::UntilIslandEnd { .. } => usize::MAX, };
704
705 let region_limit = match mode {
709 WalkMode::Range { end } => end.min(ops.len()),
710 WalkMode::UntilIslandEnd { .. } => ops.len(),
711 };
712
713 while pos < loop_bound {
714 if out.len() > limit {
717 return Err(IrError::OutputLimitExceeded { limit });
718 }
719
720 if pos >= ops.len() {
723 match mode {
724 WalkMode::Range { .. } => {
725 return Err(IrError::BufferTooShort {
726 expected: pos + 1,
727 actual: ops.len(),
728 });
729 }
730 WalkMode::UntilIslandEnd { .. } => break, }
732 }
733
734 let opcode = Opcode::from_byte(ops[pos])?;
735 pos += 1; if state.pending_tag_close && opcode != Opcode::DynAttr {
740 close_start_tag(out, state, limit)?;
741 }
742
743 match opcode {
744 Opcode::OpenTag | Opcode::VoidTag => {
746 let (tag_str_idx, attrs, new_pos) = read_tag_with_attrs(ops, pos, strings)?;
747 let tag = strings.get(tag_str_idx)?;
748 let has_content = opcode == Opcode::OpenTag;
749 emit_open_tag(out, tag, &attrs, has_content, state, limit)?;
750 state.nodes += 1;
751 pos = new_pos;
752 }
753
754 Opcode::CloseTag => {
755 let str_idx = read_u32(ops, pos)?;
756 pos += 4;
757 let tag = strings.get(str_idx)?;
758 if !is_valid_name(tag) {
761 return Err(IrError::InvalidName(tag.to_string()));
762 }
763 leave_element(out, tag, state, limit)?;
764 push_checked(out, "</", limit)?;
765 push_checked(out, tag, limit)?;
766 push_checked(out, ">", limit)?;
767 }
768
769 Opcode::Text => {
770 let str_idx = read_u32(ops, pos)?;
771 pos += 4;
772 let text = strings.get(str_idx)?;
773 if let Some(parent) = state.text_only.as_mut() {
774 if !parent.value_bound {
775 write_text_only(out, parent, text, limit)?;
776 }
777 } else {
778 push_escaped_html(out, text, limit)?;
779 if let Some(option) = state.option_by_text.as_mut() {
780 option.text.push_str(text);
781 }
782 }
783 state.nodes += 1;
784 }
785
786 Opcode::DynText => {
787 let slot_id = read_u16(ops, pos)?;
789 pos += 2;
790 let marker_id = read_u16(ops, pos)?;
791 pos += 2;
792
793 check_slot_source(module, slot_id, slots.get(slot_id));
794 let value = slots.get(slot_id).to_text();
795
796 #[cfg(any(test, feature = "__fault-injection"))]
800 #[allow(clippy::panic)]
801 if state.fault_armed && value == crate::fault_injection::FAULT_TRIGGER {
802 panic!("stand-in component panicked (FAULT_TRIGGER)");
803 }
804
805 if let Some(parent) = state.text_only.as_mut() {
806 if !parent.value_bound {
809 write_text_only(out, parent, &value, limit)?;
810 }
811 } else {
812 push_checked(out, "<!--f:t", limit)?;
813 push_u16(out, marker_id, limit)?;
814 push_checked(out, "-->", limit)?;
815 let written = if value.is_empty() {
821 "\u{200B}"
822 } else {
823 value.as_str()
824 };
825 push_escaped_html(out, written, limit)?;
826 push_checked(out, "<!--/f:t", limit)?;
827 push_u16(out, marker_id, limit)?;
828 push_checked(out, "-->", limit)?;
829 if let Some(option) = state.option_by_text.as_mut() {
830 option.text.push_str(written);
831 }
832 }
833 state.nodes += 1;
834 }
835
836 Opcode::DynAttr => {
837 let attr_str_idx = read_u32(ops, pos)?;
839 let slot_id = read_u16(ops, pos + 4)?;
840 pos += 6;
841 check_slot_source(module, slot_id, slots.get(slot_id));
842
843 if state.pending_tag_close {
844 let attr_name = strings.get(attr_str_idx)?;
845 if consume_control_attr(state, attr_name, Prop::Slot(slots.get(slot_id))) {
848 continue;
849 }
850 let text;
853 let value = match slots.get(slot_id) {
854 SlotValue::Bool(false) | SlotValue::Null => None,
860 SlotValue::Bool(true) => Some(AttrValue::Bare),
861 other => {
862 text = other.to_text();
863 Some(AttrValue::Valued(text.as_str()))
868 }
869 };
870 if let Some(value) = value {
871 let tag = std::mem::take(&mut state.pending_tag_name);
875 emit_attr(out, &tag, attr_name, value, limit)?;
876 state.pending_tag_name = tag;
877 }
878 }
879 }
881
882 Opcode::IslandStart => {
883 let island_id = read_u16(ops, pos)?;
885 pos += 2;
886
887 if let Some(outer) = &state.pending_island {
894 return Err(IrError::NestedIslandRoot {
895 outer: outer.id,
896 inner: island_id,
897 });
898 }
899 state.islands.push(island_id);
900
901 if state.text_only.is_none() {
903 push_checked(out, "<!--f:i", limit)?;
904 push_u16(out, island_id, limit)?;
905 push_checked(out, "-->", limit)?;
906 }
907
908 if let Some(entry) = module.island_entry(island_id) {
914 let component_name = strings.get(entry.name_str_idx)?.to_string();
915
916 let inline_props = match entry.props_mode {
918 PropsMode::Inline => {
919 if entry.slot_ids.is_empty() {
920 None
921 } else {
922 let props = build_island_props(module, slots, &entry.slot_ids);
923 let json = serde_json::to_string(&props)
924 .unwrap_or_else(|_| "{}".to_string());
925 if json.len() > INLINE_PROPS_MAX_BYTES && !state.force_inline_props
926 {
927 state
933 .script_tag_props
934 .insert(island_id, serde_json::Value::Object(props));
935 None
936 } else {
937 Some(json)
938 }
939 }
940 }
941 PropsMode::ScriptTag if state.force_inline_props => {
942 if entry.slot_ids.is_empty() {
946 None
947 } else {
948 let props = build_island_props(module, slots, &entry.slot_ids);
949 Some(
950 serde_json::to_string(&props)
951 .unwrap_or_else(|_| "{}".to_string()),
952 )
953 }
954 }
955 PropsMode::ScriptTag => {
956 if !entry.slot_ids.is_empty() {
958 let props = build_island_props(module, slots, &entry.slot_ids);
959 state
960 .script_tag_props
961 .insert(island_id, serde_json::Value::Object(props));
962 }
963 None
964 }
965 PropsMode::Deferred => None,
966 };
967
968 state.pending_island = Some(IslandPending {
969 id: island_id,
970 component_name,
971 inline_props,
972 trigger: entry.trigger,
973 });
974 } else {
975 #[cfg(feature = "tracing")]
976 tracing::warn!(
977 island_id = island_id,
978 "ISLAND_START names an island that is not in the island table — \
979 markers emitted but no data-forma-* attributes, so it will not hydrate \
980 (module was not validated)"
981 );
982 }
983 }
984
985 Opcode::IslandEnd => {
986 let island_id = read_u16(ops, pos)?;
988 pos += 2;
989 if state.islands.last() == Some(&island_id) {
990 state.islands.pop();
991 }
992
993 if state.pending_island.take().is_some() {
999 #[cfg(feature = "tracing")]
1000 tracing::warn!(
1001 island_id = island_id,
1002 "ISLAND_START was not followed by an element — island attributes dropped, \
1003 this island will not hydrate (compiler should emit a shell element)"
1004 );
1005 }
1006
1007 if let WalkMode::UntilIslandEnd { target_island_id } = mode {
1009 if island_id == target_island_id {
1010 if state.pending_tag_close {
1012 close_start_tag(out, state, limit)?;
1013 }
1014 return Ok(());
1015 }
1016 }
1017
1018 if state.text_only.is_none() {
1022 push_checked(out, "<!--/f:i", limit)?;
1023 push_u16(out, island_id, limit)?;
1024 push_checked(out, "-->", limit)?;
1025 }
1026 }
1027
1028 Opcode::Comment => {
1029 let str_idx = read_u32(ops, pos)?;
1031 pos += 4;
1032 let text = strings.get(str_idx)?;
1033 push_checked(out, "<!--", limit)?;
1034 if text.starts_with('>') || text.starts_with("->") {
1040 push_checked(out, " ", limit)?;
1041 }
1042 push_checked(out, &text.replace("--", "--"), limit)?;
1043 push_checked(out, "-->", limit)?;
1044 }
1045
1046 Opcode::ShowIf => {
1048 let slot_id = read_u16(ops, pos)?;
1050 let then_len = read_u32(ops, pos + 2)? as usize;
1051 let else_len = read_u32(ops, pos + 6)? as usize;
1052 pos += 10;
1053
1054 check_slot_source(module, slot_id, slots.get(slot_id));
1055 let condition = slots.get(slot_id).as_bool();
1056 let then_start = pos;
1057 let then_end = bounded_offset(pos, then_len, region_limit)?;
1061 if then_end >= region_limit || ops[then_end] != Opcode::ShowElse as u8 {
1065 return Err(IrError::InvalidSection);
1066 }
1067 let else_start = then_end + 1; let else_end = bounded_offset(else_start, else_len, region_limit)?;
1069
1070 let markers = state.text_only.is_none();
1073 if markers {
1074 push_checked(out, "<!--f:s", limit)?;
1075 push_u16(out, slot_id, limit)?;
1076 push_checked(out, "-->", limit)?;
1077 }
1078
1079 if condition {
1080 walk_range(
1081 module,
1082 slots,
1083 ops,
1084 then_start,
1085 then_end,
1086 out,
1087 state,
1088 depth + 1,
1089 )?;
1090 } else {
1091 walk_range(
1092 module,
1093 slots,
1094 ops,
1095 else_start,
1096 else_end,
1097 out,
1098 state,
1099 depth + 1,
1100 )?;
1101 }
1102
1103 if markers {
1104 push_checked(out, "<!--/f:s", limit)?;
1105 push_u16(out, slot_id, limit)?;
1106 push_checked(out, "-->", limit)?;
1107 }
1108
1109 pos = else_end;
1111 }
1112
1113 Opcode::ShowElse => {
1114 }
1118
1119 Opcode::Switch => {
1130 let slot_id = read_u16(ops, pos)?;
1131 let case_count = read_u16(ops, pos + 2)? as usize;
1132 pos += 4;
1133
1134 let mut cases =
1138 Vec::with_capacity(case_count.min(ops.len().saturating_sub(pos) / 8));
1139 for _ in 0..case_count {
1140 let val_str_idx = read_u32(ops, pos)?;
1141 let body_len = read_u32(ops, pos + 4)? as usize;
1142 cases.push((val_str_idx, body_len));
1143 pos += 8;
1144 }
1145
1146 check_slot_source(module, slot_id, slots.get(slot_id));
1148 let slot_text = slots.get(slot_id).to_text();
1149 let mut body_pos = pos;
1150 let mut matched = false;
1154
1155 for (val_str_idx, body_len) in &cases {
1156 let case_end = bounded_offset(body_pos, *body_len, region_limit)?;
1160 let case_val = strings.get(*val_str_idx)?;
1161 if !matched && case_val == slot_text {
1162 matched = true;
1163 walk_range(
1164 module,
1165 slots,
1166 ops,
1167 body_pos,
1168 case_end,
1169 out,
1170 state,
1171 depth + 1,
1172 )?;
1173 }
1174 body_pos = case_end;
1175 }
1176
1177 pos = body_pos; }
1179
1180 Opcode::List => {
1181 let slot_id = read_u16(ops, pos)?;
1183 let item_slot_id = read_u16(ops, pos + 2)?;
1184 let body_len = read_u32(ops, pos + 4)? as usize;
1185 pos += 8;
1186
1187 check_slot_source(module, slot_id, slots.get(slot_id));
1188
1189 let body_start = pos;
1190 let body_end = bounded_offset(pos, body_len, region_limit)?;
1191
1192 let list_marker_id = slot_id;
1195 let markers = state.text_only.is_none();
1196 if markers {
1197 push_checked(out, "<!--f:l", limit)?;
1198 push_u16(out, list_marker_id, limit)?;
1199 push_checked(out, "-->", limit)?;
1200 }
1201
1202 let items = match slots.get(slot_id) {
1208 SlotValue::Array(a) => Some(a.clone()),
1209 _ => None,
1210 };
1211
1212 if let Some(items) = items.filter(|a| !a.is_empty()) {
1213 state.list_depth += 1;
1214 if state.list_depth > MAX_LIST_DEPTH {
1215 return Err(IrError::ListDepthExceeded {
1216 max: MAX_LIST_DEPTH,
1217 });
1218 }
1219 let remaining = state
1232 .max_list_iterations
1233 .saturating_sub(state.list_iterations);
1234 if items.len() > remaining {
1235 return Err(IrError::ListBudgetExceeded {
1236 max: state.max_list_iterations,
1237 });
1238 }
1239 for item in items.iter() {
1240 if state.list_iterations >= state.max_list_iterations {
1247 return Err(IrError::ListBudgetExceeded {
1248 max: state.max_list_iterations,
1249 });
1250 }
1251 state.list_iterations += 1;
1252 let mark = state.slot_journal.len();
1257 let previous = slots.replace(item_slot_id, item.clone());
1258 state.slot_journal.push((item_slot_id, previous));
1259
1260 let walked = walk_range(
1261 module,
1262 slots,
1263 ops,
1264 body_start,
1265 body_end,
1266 out,
1267 state,
1268 depth + 1,
1269 );
1270 state.unwind_slot_journal(slots, mark);
1274 walked?;
1275
1276 state.pending_list_key = None;
1281 }
1282 state.list_depth -= 1;
1283 }
1284 state.pending_list_key = None;
1285
1286 if markers {
1287 push_checked(out, "<!--/f:l", limit)?;
1288 push_u16(out, list_marker_id, limit)?;
1289 push_checked(out, "-->", limit)?;
1290 }
1291
1292 pos = body_end;
1293 }
1294
1295 Opcode::TryStart => {
1296 let fallback_len = read_u32(ops, pos)? as usize;
1298 pos += 4;
1299 fallback_stack.push(fallback_len);
1302 }
1303
1304 Opcode::Fallback => {
1305 if let Some(skip) = fallback_stack.pop() {
1308 pos = bounded_offset(pos, skip, region_limit)?;
1309 }
1310 }
1311
1312 Opcode::Preload => {
1313 if pos >= ops.len() {
1314 return Err(IrError::BufferTooShort {
1315 expected: pos + 1,
1316 actual: ops.len(),
1317 });
1318 }
1319 let resource_type = ops[pos];
1320 let url_str_idx = read_u32(ops, pos + 1)?;
1321 let url = strings.get(url_str_idx)?;
1322 pos += 5;
1323
1324 let (as_val, type_attr) = match resource_type {
1325 1 => ("font", " type=\"font/woff2\" crossorigin"),
1326 2 => ("style", ""),
1327 3 => ("script", ""),
1328 4 => ("image", ""),
1329 _ => ("fetch", ""),
1330 };
1331 push_checked(out, "<link rel=\"preload\" href=\"", limit)?;
1339 push_escaped_attr(out, url, limit)?;
1340 push_checked(out, "\" as=\"", limit)?;
1341 push_checked(out, as_val, limit)?;
1342 push_checked(out, "\"", limit)?;
1343 push_checked(out, type_attr, limit)?;
1344 push_checked(out, ">\n", limit)?;
1345 }
1346
1347 Opcode::ListItemKey => {
1348 let key_str_idx = read_u32(ops, pos)?;
1350 pos += 4;
1351 let key_val = strings.get(key_str_idx)?;
1352 state.pending_list_key = Some(key_val.to_string());
1353 }
1354
1355 Opcode::Prop => {
1356 let src_slot_id = read_u16(ops, pos)?;
1358 let prop_str_idx = read_u32(ops, pos + 2)?;
1359 let target_slot_id = read_u16(ops, pos + 6)?;
1360 pos += 8;
1361
1362 let prop_name = strings.get(prop_str_idx)?;
1363 let value = slots.get(src_slot_id).get_property(prop_name);
1364 let previous = slots.replace(target_slot_id, value);
1369 state.journal_write(target_slot_id, previous);
1370 }
1371 }
1372 }
1373
1374 match mode {
1376 WalkMode::Range { .. } => {
1377 if state.pending_tag_close {
1387 close_start_tag(out, state, limit)?;
1388 }
1389 Ok(())
1390 }
1391 WalkMode::UntilIslandEnd { target_island_id } => {
1392 Err(IrError::IslandNotFound(target_island_id))
1395 }
1396 }
1397}
1398
1399fn build_island_props(
1415 module: &IrModule,
1416 slots: &SlotData,
1417 slot_ids: &[u16],
1418) -> serde_json::Map<String, serde_json::Value> {
1419 let mut map = serde_json::Map::new();
1420 for &slot_id in slot_ids {
1421 if let Some(entry) = module.slot_entry(slot_id) {
1423 if let Ok(name) = module.strings.get(entry.name_str_idx) {
1424 if map.contains_key(name) {
1425 #[cfg(feature = "tracing")]
1426 tracing::warn!(
1427 slot_id = slot_id,
1428 name = name,
1429 "Island declares two slots with the same name — keeping the first, \
1430 the later value is not reachable from the props object"
1431 );
1432 continue;
1433 }
1434 let value = slots.get(slot_id).to_json();
1435 map.insert(name.to_string(), value);
1436 }
1437 } else {
1438 #[cfg(feature = "tracing")]
1443 tracing::warn!(
1444 slot_id = slot_id,
1445 "Island references a slot id that is not in the slot table — prop dropped"
1446 );
1447 }
1448 }
1449 map
1450}
1451
1452fn bounded_offset(base: usize, len: usize, limit: usize) -> Result<usize, IrError> {
1462 base.checked_add(len)
1463 .filter(|e| *e <= limit)
1464 .ok_or(IrError::InvalidSection)
1465}
1466
1467fn is_valid_name(name: &str) -> bool {
1481 let mut chars = name.chars();
1482 match chars.next() {
1483 Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':' => {}
1484 _ => return false,
1485 }
1486 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'))
1487}
1488
1489fn is_event_handler_attr(name: &str) -> bool {
1491 name.len() >= 2 && name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
1492}
1493
1494const URL_ATTRS: [&str; 8] = [
1502 "href",
1503 "src",
1504 "action",
1505 "formaction",
1506 "xlink:href",
1507 "poster",
1508 "background",
1509 "data",
1510];
1511
1512const IMAGE_CONTEXT_TAGS: [&str; 13] = [
1516 "img", "image", "video", "audio", "source", "body", "table", "thead", "tbody", "tfoot", "tr",
1517 "td", "th",
1518];
1519
1520fn is_url_attr(name: &str) -> bool {
1522 URL_ATTRS.iter().any(|a| name.eq_ignore_ascii_case(a))
1523}
1524
1525fn is_dangerous_url(value: &str, tag: &str) -> bool {
1534 let normalized: String = value
1535 .chars()
1536 .filter(|c| {
1537 let n = *c as u32;
1538 !((n <= 0x20) || (0x7F..=0x9F).contains(&n))
1539 })
1540 .collect();
1541 let lower = normalized.to_ascii_lowercase();
1542
1543 if lower.starts_with("javascript") || lower.starts_with("vbscript") {
1544 return true;
1545 }
1546 if lower.starts_with("data:text/html") {
1547 return true;
1548 }
1549 if lower.starts_with("data:image/svg+xml") {
1554 return !IMAGE_CONTEXT_TAGS
1555 .iter()
1556 .any(|t| tag.eq_ignore_ascii_case(t));
1557 }
1558 false
1559}
1560
1561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1568enum AttrValue<'a> {
1569 Bare,
1571 Valued(&'a str),
1573}
1574
1575fn emit_attr(
1582 out: &mut String,
1583 tag: &str,
1584 key: &str,
1585 val: AttrValue<'_>,
1586 limit: usize,
1587) -> Result<(), IrError> {
1588 if !is_valid_name(key) {
1589 #[cfg(feature = "tracing")]
1593 tracing::warn!(attr = key, "Dropping attribute with an unsafe name");
1594 return Ok(());
1595 }
1596 if is_event_handler_attr(key) {
1597 #[cfg(feature = "tracing")]
1598 tracing::warn!(attr = key, "Dropping event-handler attribute from IR");
1599 return Ok(());
1600 }
1601 if let AttrValue::Valued(v) = val {
1602 if is_url_attr(key) && is_dangerous_url(v, tag) {
1603 #[cfg(feature = "tracing")]
1604 tracing::warn!(attr = key, "Dropping URL attribute with a dangerous scheme");
1605 return Ok(());
1606 }
1607 }
1608
1609 push_checked(out, " ", limit)?;
1610 push_checked(out, key, limit)?;
1611 if let AttrValue::Valued(v) = val {
1612 push_checked(out, "=\"", limit)?;
1613 push_escaped_attr(out, v, limit)?;
1614 push_checked(out, "\"", limit)?;
1615 }
1616 Ok(())
1617}
1618
1619fn emit_open_tag(
1625 out: &mut String,
1626 tag: &str,
1627 attrs: &[(&str, &str)],
1628 has_content: bool,
1629 state: &mut WalkState,
1630 limit: usize,
1631) -> Result<(), IrError> {
1632 if !is_valid_name(tag) {
1633 return Err(IrError::InvalidName(tag.to_string()));
1634 }
1635 if has_content {
1636 enter_element(out, tag, state, limit)?;
1637 }
1638 push_checked(out, "<", limit)?;
1639 push_checked(out, tag, limit)?;
1640
1641 for (key, val) in attrs {
1642 if consume_control_attr(state, key, Prop::Static(val)) {
1645 continue;
1646 }
1647 let value = if val.is_empty() {
1651 AttrValue::Bare
1652 } else {
1653 AttrValue::Valued(val)
1654 };
1655 emit_attr(out, tag, key, value, limit)?;
1656 }
1657
1658 if let Some(island) = state.pending_island.take() {
1660 push_checked(out, " data-forma-island=\"", limit)?;
1661 push_u16(out, island.id, limit)?;
1662 push_checked(out, "\" data-forma-component=\"", limit)?;
1663 push_escaped_attr(out, &island.component_name, limit)?;
1664 push_checked(out, "\" data-forma-status=\"pending\"", limit)?;
1665 let trigger_str = match island.trigger {
1666 IslandTrigger::Load => "load",
1667 IslandTrigger::Visible => "visible",
1668 IslandTrigger::Interaction => "interaction",
1669 IslandTrigger::Idle => "idle",
1670 };
1671 push_checked(out, " data-forma-hydrate=\"", limit)?;
1672 push_checked(out, trigger_str, limit)?;
1673 push_checked(out, "\"", limit)?;
1674 if let Some(ref props_json) = island.inline_props {
1675 push_checked(out, " data-forma-props=\"", limit)?;
1676 push_escaped_attr(out, props_json, limit)?;
1677 push_checked(out, "\"", limit)?;
1678 }
1679 }
1680
1681 if let Some(key) = state.pending_list_key.take() {
1683 push_checked(out, " data-forma-key=\"", limit)?;
1684 push_escaped_attr(out, &key, limit)?;
1685 push_checked(out, "\"", limit)?;
1686 }
1687
1688 state.pending_tag_close = true;
1690 state.pending_tag_name.clear();
1691 state.pending_tag_name.push_str(tag);
1692 Ok(())
1693}
1694
1695#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1729enum TextOnlyTag {
1730 Textarea,
1731 Title,
1732 Style,
1733 Script,
1734}
1735
1736impl TextOnlyTag {
1737 fn from_tag(tag: &str) -> Option<Self> {
1738 [Self::Textarea, Self::Title, Self::Style, Self::Script]
1739 .into_iter()
1740 .find(|t| tag.eq_ignore_ascii_case(t.name()))
1741 }
1742
1743 fn name(self) -> &'static str {
1744 match self {
1745 TextOnlyTag::Textarea => "textarea",
1746 TextOnlyTag::Title => "title",
1747 TextOnlyTag::Style => "style",
1748 TextOnlyTag::Script => "script",
1749 }
1750 }
1751}
1752
1753#[derive(Debug)]
1755struct TextOnlyParent {
1756 tag: TextOnlyTag,
1757 started: bool,
1760 value_bound: bool,
1763}
1764
1765fn write_text_only(
1775 out: &mut String,
1776 parent: &mut TextOnlyParent,
1777 text: &str,
1778 limit: usize,
1779) -> Result<(), IrError> {
1780 if text.is_empty() {
1781 return Ok(());
1782 }
1783 if parent.tag == TextOnlyTag::Textarea
1784 && !parent.started
1785 && (text.starts_with('\n') || text.starts_with('\r'))
1786 {
1787 push_checked(out, "\n", limit)?;
1788 }
1789 parent.started = true;
1790 push_escaped_html(out, text, limit)
1791}
1792
1793#[derive(Debug, Default)]
1802enum PendingControl {
1803 #[default]
1804 None,
1805 Textarea { value: Option<String> },
1807 Select {
1809 value: Option<SlotValue>,
1810 multiple: bool,
1811 },
1812 OptionTag { value: Option<String> },
1815}
1816
1817#[derive(Debug)]
1832struct SelectBinding {
1833 wanted: SelectWanted,
1834 matched: bool,
1836}
1837
1838#[derive(Debug)]
1839enum SelectWanted {
1840 One(String),
1841 Any(Vec<String>),
1842}
1843
1844impl SelectBinding {
1845 fn new(value: &SlotValue, multiple: bool) -> Self {
1846 let wanted = match value {
1847 SlotValue::Array(items) if multiple => {
1848 SelectWanted::Any(items.iter().map(js_string).collect())
1849 }
1850 other => SelectWanted::One(other.to_text()),
1852 };
1853 SelectBinding {
1854 wanted,
1855 matched: false,
1856 }
1857 }
1858
1859 fn selects(&mut self, option_value: &str) -> bool {
1861 match &self.wanted {
1862 SelectWanted::Any(values) => values.iter().any(|v| v == option_value),
1863 SelectWanted::One(value) => {
1864 let hit = !self.matched && value == option_value;
1865 self.matched |= hit;
1866 hit
1867 }
1868 }
1869 }
1870}
1871
1872fn js_string(value: &SlotValue) -> String {
1875 match value {
1876 SlotValue::Null => "null".to_string(),
1877 other => other.to_text(),
1878 }
1879}
1880
1881#[derive(Debug)]
1884struct OptionByText {
1885 insert_at: usize,
1888 text: String,
1890}
1891
1892#[derive(Clone, Copy)]
1894enum Prop<'a> {
1895 Static(&'a str),
1896 Slot(&'a SlotValue),
1897}
1898
1899impl Prop<'_> {
1900 fn control_text(self) -> String {
1902 match self {
1903 Prop::Static(s) => s.to_string(),
1904 Prop::Slot(v) => v.to_text(),
1905 }
1906 }
1907
1908 fn is_written(self) -> bool {
1911 !matches!(
1912 self,
1913 Prop::Slot(SlotValue::Null) | Prop::Slot(SlotValue::Bool(false))
1914 )
1915 }
1916
1917 fn written(self) -> Option<String> {
1920 match self {
1921 _ if !self.is_written() => None,
1922 Prop::Slot(SlotValue::Bool(true)) => Some(String::new()),
1923 other => Some(other.control_text()),
1924 }
1925 }
1926
1927 fn to_slot(self) -> SlotValue {
1928 match self {
1929 Prop::Static(s) => SlotValue::Text(s.to_string()),
1930 Prop::Slot(v) => v.clone(),
1931 }
1932 }
1933}
1934
1935fn consume_control_attr(state: &mut WalkState, key: &str, prop: Prop<'_>) -> bool {
1940 match &mut state.pending_control {
1941 PendingControl::None => false,
1942 PendingControl::Textarea { value } => {
1943 if key != "value" {
1944 return false;
1945 }
1946 *value = Some(prop.control_text());
1947 true
1948 }
1949 PendingControl::Select { value, multiple } => match key {
1950 "value" => {
1951 *value = Some(prop.to_slot());
1952 true
1953 }
1954 "multiple" => {
1955 *multiple |= prop.is_written();
1956 false
1957 }
1958 _ => false,
1959 },
1960 PendingControl::OptionTag { value } => match key {
1961 "selected" => true,
1964 "value" => {
1965 if value.is_none() {
1967 *value = prop.written();
1968 }
1969 false
1970 }
1971 _ => false,
1972 },
1973 }
1974}
1975
1976fn enter_element(
1979 out: &mut String,
1980 tag: &str,
1981 state: &mut WalkState,
1982 limit: usize,
1983) -> Result<(), IrError> {
1984 let is = |name: &str| tag.eq_ignore_ascii_case(name);
1985 if is("option") || is("optgroup") {
1987 finish_option_by_text(out, state, limit)?;
1988 }
1989 state.pending_control = if is("textarea") {
1990 PendingControl::Textarea { value: None }
1991 } else if is("select") {
1992 PendingControl::Select {
1993 value: None,
1994 multiple: false,
1995 }
1996 } else if is("option") && state.select.is_some() {
1997 PendingControl::OptionTag { value: None }
1998 } else {
1999 PendingControl::None
2000 };
2001 if state.text_only.is_none() {
2002 state.text_only = TextOnlyTag::from_tag(tag).map(|tag| TextOnlyParent {
2003 tag,
2004 started: false,
2005 value_bound: false,
2006 });
2007 }
2008 Ok(())
2009}
2010
2011fn leave_element(
2013 out: &mut String,
2014 tag: &str,
2015 state: &mut WalkState,
2016 limit: usize,
2017) -> Result<(), IrError> {
2018 let is = |name: &str| tag.eq_ignore_ascii_case(name);
2019 if is("option") || is("optgroup") || is("select") {
2020 finish_option_by_text(out, state, limit)?;
2021 }
2022 if is("select") {
2023 state.select = None;
2024 }
2025 if state
2026 .text_only
2027 .as_ref()
2028 .is_some_and(|parent| is(parent.tag.name()))
2029 {
2030 state.text_only = None;
2031 }
2032 Ok(())
2033}
2034
2035fn close_start_tag(out: &mut String, state: &mut WalkState, limit: usize) -> Result<(), IrError> {
2041 state.pending_tag_close = false;
2042 match std::mem::take(&mut state.pending_control) {
2043 PendingControl::None => {}
2044 PendingControl::Textarea { value } => {
2045 push_checked(out, ">", limit)?;
2046 if let (Some(value), Some(parent)) = (value, state.text_only.as_mut()) {
2047 if parent.tag == TextOnlyTag::Textarea {
2048 parent.value_bound = true;
2049 write_text_only(out, parent, &value, limit)?;
2050 }
2051 }
2052 return Ok(());
2053 }
2054 PendingControl::Select { value, multiple } => {
2055 state.select = value.map(|value| SelectBinding::new(&value, multiple));
2056 }
2057 PendingControl::OptionTag { value: Some(value) } => {
2058 if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
2059 push_checked(out, " selected", limit)?;
2060 }
2061 }
2062 PendingControl::OptionTag { value: None } => {
2063 state.option_by_text = Some(OptionByText {
2064 insert_at: out.len(),
2065 text: String::new(),
2066 });
2067 }
2068 }
2069 push_checked(out, ">", limit)
2070}
2071
2072fn finish_option_by_text(
2077 out: &mut String,
2078 state: &mut WalkState,
2079 limit: usize,
2080) -> Result<(), IrError> {
2081 let Some(option) = state.option_by_text.take() else {
2082 return Ok(());
2083 };
2084 let value = strip_and_collapse_ascii_whitespace(&option.text);
2085 if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
2086 const SELECTED: &str = " selected";
2087 if out.len().saturating_add(SELECTED.len()) > limit {
2088 return Err(IrError::OutputLimitExceeded { limit });
2089 }
2090 out.insert_str(option.insert_at, SELECTED);
2091 }
2092 Ok(())
2093}
2094
2095fn strip_and_collapse_ascii_whitespace(text: &str) -> String {
2098 let mut out = String::with_capacity(text.len());
2099 for word in text.split_ascii_whitespace() {
2100 if !out.is_empty() {
2101 out.push(' ');
2102 }
2103 out.push_str(word);
2104 }
2105 out
2106}
2107
2108pub(crate) fn read_u16(data: &[u8], pos: usize) -> Result<u16, IrError> {
2114 if pos + 2 > data.len() {
2115 return Err(IrError::BufferTooShort {
2116 expected: pos + 2,
2117 actual: data.len(),
2118 });
2119 }
2120 Ok(u16::from_le_bytes([data[pos], data[pos + 1]]))
2121}
2122
2123pub(crate) fn read_u32(data: &[u8], pos: usize) -> Result<u32, IrError> {
2125 if pos + 4 > data.len() {
2126 return Err(IrError::BufferTooShort {
2127 expected: pos + 4,
2128 actual: data.len(),
2129 });
2130 }
2131 Ok(u32::from_le_bytes([
2132 data[pos],
2133 data[pos + 1],
2134 data[pos + 2],
2135 data[pos + 3],
2136 ]))
2137}
2138
2139pub(crate) fn read_tag_with_attrs<'a>(
2144 ops: &[u8],
2145 pos: usize,
2146 strings: &'a crate::parser::StringTable,
2147) -> Result<(u32, Vec<(&'a str, &'a str)>, usize), IrError> {
2148 let tag_str_idx = read_u32(ops, pos)?;
2149 let attr_count = read_u16(ops, pos + 4)? as usize;
2150 let mut cursor = pos + 6; let mut attrs = Vec::with_capacity(attr_count.min(ops.len().saturating_sub(cursor) / 8));
2156 for _ in 0..attr_count {
2157 let key_idx = read_u32(ops, cursor)?;
2158 let val_idx = read_u32(ops, cursor + 4)?;
2159 let key = strings.get(key_idx)?;
2160 let val = strings.get(val_idx)?;
2161 attrs.push((key, val));
2162 cursor += 8;
2163 }
2164
2165 Ok((tag_str_idx, attrs, cursor))
2166}
2167
2168const MAX_ESCAPE_EXPANSION: usize = 6;
2175
2176fn push_checked(out: &mut String, s: &str, limit: usize) -> Result<(), IrError> {
2183 if out.len().saturating_add(s.len()) > limit {
2184 return Err(IrError::OutputLimitExceeded { limit });
2185 }
2186 out.push_str(s);
2187 Ok(())
2188}
2189
2190fn push_u16(out: &mut String, value: u16, limit: usize) -> Result<(), IrError> {
2196 let mut buf = [0u8; 5];
2198 let mut remaining = value;
2199 let mut start = buf.len();
2200 loop {
2201 start -= 1;
2202 buf[start] = b'0' + (remaining % 10) as u8;
2203 remaining /= 10;
2204 if remaining == 0 {
2205 break;
2206 }
2207 }
2208 match core::str::from_utf8(&buf[start..]) {
2211 Ok(digits) => push_checked(out, digits, limit),
2212 Err(e) => Err(IrError::InvalidUtf8(e.to_string())),
2213 }
2214}
2215
2216fn push_escaped_html(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
2218 if out
2220 .len()
2221 .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
2222 <= limit
2223 {
2224 for ch in text.chars() {
2225 match ch {
2226 '&' => out.push_str("&"),
2227 '<' => out.push_str("<"),
2228 '>' => out.push_str(">"),
2229 _ => out.push(ch),
2230 }
2231 }
2232 return Ok(());
2233 }
2234 for ch in text.chars() {
2235 match ch {
2236 '&' => push_checked(out, "&", limit)?,
2237 '<' => push_checked(out, "<", limit)?,
2238 '>' => push_checked(out, ">", limit)?,
2239 _ => {
2240 if out.len() >= limit {
2241 return Err(IrError::OutputLimitExceeded { limit });
2242 }
2243 out.push(ch);
2244 }
2245 }
2246 }
2247 Ok(())
2248}
2249
2250fn push_escaped_attr(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
2252 if out
2253 .len()
2254 .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
2255 <= limit
2256 {
2257 for ch in text.chars() {
2258 match ch {
2259 '&' => out.push_str("&"),
2260 '"' => out.push_str("""),
2261 '<' => out.push_str("<"),
2262 '>' => out.push_str(">"),
2263 _ => out.push(ch),
2264 }
2265 }
2266 return Ok(());
2267 }
2268 for ch in text.chars() {
2269 match ch {
2270 '&' => push_checked(out, "&", limit)?,
2271 '"' => push_checked(out, """, limit)?,
2272 '<' => push_checked(out, "<", limit)?,
2273 '>' => push_checked(out, ">", limit)?,
2274 _ => {
2275 if out.len() >= limit {
2276 return Err(IrError::OutputLimitExceeded { limit });
2277 }
2278 out.push(ch);
2279 }
2280 }
2281 }
2282 Ok(())
2283}
2284
2285#[cfg(test)]
2290mod tests {
2291 use super::*;
2292 use crate::parser::test_helpers::{
2293 build_minimal_ir, encode_close_tag, encode_list, encode_open_tag, encode_preload,
2294 encode_show_if, encode_switch, encode_text, encode_try, encode_void_tag,
2295 };
2296 use crate::slot::{SlotData, SlotValue};
2297
2298 fn encode_dyn_text(slot_id: u16, marker_id: u16) -> Vec<u8> {
2302 let mut buf = Vec::new();
2303 buf.push(0x05); buf.extend_from_slice(&slot_id.to_le_bytes());
2305 buf.extend_from_slice(&marker_id.to_le_bytes());
2306 buf
2307 }
2308
2309 fn encode_island_start(island_id: u16) -> Vec<u8> {
2311 let mut buf = vec![0x0B];
2312 buf.extend_from_slice(&island_id.to_le_bytes());
2313 buf
2314 }
2315
2316 fn encode_island_end(island_id: u16) -> Vec<u8> {
2318 let mut buf = vec![0x0C];
2319 buf.extend_from_slice(&island_id.to_le_bytes());
2320 buf
2321 }
2322
2323 fn encode_comment(str_idx: u32) -> Vec<u8> {
2325 let mut buf = vec![0x10];
2326 buf.extend_from_slice(&str_idx.to_le_bytes());
2327 buf
2328 }
2329
2330 fn walk_static(strings: &[&str], opcodes: &[u8]) -> String {
2332 let data = build_minimal_ir(strings, &[], opcodes, &[]);
2333 let module = IrModule::parse(&data).unwrap();
2334 let slots = SlotData::new(0);
2335 walk_to_html(&module, &slots).unwrap()
2336 }
2337
2338 fn walk_with_slots(
2340 strings: &[&str],
2341 slot_decls: &[(u16, u32, u8, u8, &[u8])],
2342 opcodes: &[u8],
2343 slots: &SlotData,
2344 ) -> String {
2345 let data = build_minimal_ir(strings, slot_decls, opcodes, &[]);
2346 let module = IrModule::parse(&data).unwrap();
2347 walk_to_html(&module, slots).unwrap()
2348 }
2349
2350 fn walk_with_islands(
2352 strings: &[&str],
2353 slot_decls: &[(u16, u32, u8, u8, &[u8])],
2354 island_decls: &[(u16, u8, u8, u32, u32, &[u16])],
2355 opcodes: &[u8],
2356 slots: &SlotData,
2357 ) -> String {
2358 let data = build_minimal_ir(strings, slot_decls, opcodes, island_decls);
2359 let module = IrModule::parse(&data).unwrap();
2360 walk_to_html(&module, slots).unwrap()
2361 }
2362
2363 fn encode_list_item_key(key_str_idx: u32) -> Vec<u8> {
2365 let mut buf = vec![0x11]; buf.extend_from_slice(&key_str_idx.to_le_bytes());
2367 buf
2368 }
2369
2370 fn encode_dyn_attr(attr_str_idx: u32, slot_id: u16) -> Vec<u8> {
2372 let mut bytes = vec![0x06]; bytes.extend_from_slice(&attr_str_idx.to_le_bytes());
2374 bytes.extend_from_slice(&slot_id.to_le_bytes());
2375 bytes
2376 }
2377
2378 #[test]
2381 fn walk_static_div() {
2382 let mut opcodes = Vec::new();
2385 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
2386 opcodes.extend_from_slice(&encode_text(3));
2387 opcodes.extend_from_slice(&encode_close_tag(0));
2388
2389 let html = walk_static(&["div", "class", "container", "Hello"], &opcodes);
2390 assert_eq!(html, r#"<div class="container">Hello</div>"#);
2391 }
2392
2393 #[test]
2396 fn walk_void_tag() {
2397 let opcodes = encode_void_tag(0, &[(1, 2)]);
2400
2401 let html = walk_static(&["input", "type", "email"], &opcodes);
2402 assert_eq!(html, r#"<input type="email">"#);
2403 }
2404
2405 #[test]
2408 fn walk_nested_elements() {
2409 let mut opcodes = Vec::new();
2412 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2413 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
2414 opcodes.extend_from_slice(&encode_text(2));
2415 opcodes.extend_from_slice(&encode_close_tag(1));
2416 opcodes.extend_from_slice(&encode_close_tag(0));
2417
2418 let html = walk_static(&["div", "span", "Hi"], &opcodes);
2419 assert_eq!(html, "<div><span>Hi</span></div>");
2420 }
2421
2422 #[test]
2425 fn walk_text_escaping() {
2426 let opcodes = encode_text(0);
2428
2429 let html = walk_static(&["<script>alert('xss')</script>"], &opcodes);
2430 assert_eq!(html, "<script>alert('xss')</script>");
2431 }
2432
2433 #[test]
2436 fn walk_attr_escaping() {
2437 let opcodes = encode_open_tag(0, &[(1, 2)]);
2440
2441 let html = walk_static(&["div", "title", "a\"b"], &opcodes);
2442 assert_eq!(html, r#"<div title="a"b">"#);
2443 }
2444
2445 #[test]
2448 fn walk_empty_element() {
2449 let mut opcodes = Vec::new();
2451 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2452 opcodes.extend_from_slice(&encode_close_tag(0));
2453
2454 let html = walk_static(&["div"], &opcodes);
2455 assert_eq!(html, "<div></div>");
2456 }
2457
2458 #[test]
2461 fn walk_dyn_text() {
2462 let opcodes = encode_dyn_text(0, 0);
2466
2467 let mut slots = SlotData::new(1);
2468 slots.set(0, SlotValue::Text("World".to_string()));
2469
2470 let html = walk_with_slots(&["greeting"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2471 assert_eq!(html, "<!--f:t0-->World<!--/f:t0-->");
2472 }
2473
2474 #[test]
2475 fn walk_dyn_text_null_emits_zwsp() {
2476 let opcodes = encode_dyn_text(0, 0);
2479 let slots = SlotData::new(1); let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2482 assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
2483 }
2484
2485 #[test]
2486 fn walk_dyn_text_empty_string_emits_zwsp() {
2487 let opcodes = encode_dyn_text(0, 0);
2489 let mut slots = SlotData::new(1);
2490 slots.set(0, SlotValue::Text(String::new()));
2491
2492 let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2493 assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
2494 }
2495
2496 #[test]
2499 fn walk_dyn_text_escaping() {
2500 let opcodes = encode_dyn_text(0, 1);
2502
2503 let mut slots = SlotData::new(1);
2504 slots.set(0, SlotValue::Text("<b>bold</b>".to_string()));
2505
2506 let html = walk_with_slots(&["content"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2507 assert_eq!(html, "<!--f:t1--><b>bold</b><!--/f:t1-->");
2508 }
2509
2510 #[test]
2513 fn walk_island_markers() {
2514 let mut opcodes = Vec::new();
2518 opcodes.extend_from_slice(&encode_island_start(0));
2519 opcodes.extend_from_slice(&encode_text(0));
2520 opcodes.extend_from_slice(&encode_island_end(0));
2521
2522 let slots = SlotData::new(0);
2523 let html = walk_with_islands(
2524 &["Hello"],
2525 &[],
2526 &[(0, 0x01, 0x01, 0, 0, &[])],
2527 &opcodes,
2528 &slots,
2529 );
2530 assert_eq!(html, "<!--f:i0-->Hello<!--/f:i0-->");
2531 }
2532
2533 #[test]
2536 fn walk_comment() {
2537 let opcodes = encode_comment(0);
2538
2539 let html = walk_static(&["This is a comment"], &opcodes);
2540 assert_eq!(html, "<!--This is a comment-->");
2541 }
2542
2543 #[test]
2546 fn walk_boolean_attr() {
2547 let opcodes = encode_void_tag(0, &[(1, 2)]);
2550
2551 let html = walk_static(&["input", "disabled", ""], &opcodes);
2552 assert_eq!(html, "<input disabled>");
2553 }
2554
2555 fn walk_dyn_attr_with(value: SlotValue) -> String {
2559 let mut opcodes = encode_void_tag(0, &[]);
2560 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
2561 let mut slots = SlotData::new(1);
2562 slots.set(0, value);
2563 walk_with_slots(
2564 &["input", "disabled"],
2565 &[(0, 1, 0x02, 0x00, &[])],
2566 &opcodes,
2567 &slots,
2568 )
2569 }
2570
2571 #[test]
2572 fn dyn_attr_bool_matches_the_client_renderer() {
2573 assert_eq!(
2581 walk_dyn_attr_with(SlotValue::Bool(true)),
2582 "<input disabled>",
2583 "true must render the bare attribute"
2584 );
2585 assert_eq!(
2586 walk_dyn_attr_with(SlotValue::Bool(false)),
2587 "<input>",
2588 "false must omit the attribute, not write disabled=\"false\""
2589 );
2590 assert_eq!(
2591 walk_dyn_attr_with(SlotValue::Null),
2592 "<input>",
2593 "null must omit the attribute"
2594 );
2595 }
2596
2597 #[test]
2598 fn dyn_attr_empty_string_is_a_value_not_an_absence() {
2599 assert_eq!(
2603 walk_dyn_attr_with(SlotValue::Text(String::new())),
2604 "<input disabled=\"\">"
2605 );
2606 assert_eq!(
2607 walk_dyn_attr_with(SlotValue::Text("yes".into())),
2608 "<input disabled=\"yes\">"
2609 );
2610 assert_eq!(
2612 walk_dyn_attr_with(SlotValue::Number(0.0)),
2613 "<input disabled=\"0\">"
2614 );
2615 }
2616
2617 #[test]
2620 fn walk_multiple_attrs() {
2621 let mut opcodes = Vec::new();
2624 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2), (3, 4), (5, 6)]));
2625 opcodes.extend_from_slice(&encode_close_tag(0));
2626
2627 let html = walk_static(
2628 &["div", "id", "main", "class", "container", "data-x", "42"],
2629 &opcodes,
2630 );
2631 assert_eq!(
2632 html,
2633 r#"<div id="main" class="container" data-x="42"></div>"#
2634 );
2635 }
2636
2637 #[test]
2640 fn walk_show_if_true_branch() {
2641 let then_ops = encode_text(1); let else_ops = encode_text(2); let opcodes = encode_show_if(0, &then_ops, &else_ops);
2647
2648 let mut slots = SlotData::new(1);
2649 slots.set(0, SlotValue::Bool(true));
2650
2651 let html = walk_with_slots(
2652 &["visible", "Yes", "No"],
2653 &[(0, 0, 0x02, 0x00, &[])], &opcodes,
2655 &slots,
2656 );
2657 assert!(html.contains("Yes"), "should contain then-branch text");
2658 assert!(!html.contains("No"), "should NOT contain else-branch text");
2659 assert!(html.contains("<!--f:s0-->"), "should have opening marker");
2660 assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
2661 }
2662
2663 #[test]
2666 fn walk_show_if_false_branch() {
2667 let then_ops = encode_text(1);
2670 let else_ops = encode_text(2);
2671 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2672
2673 let mut slots = SlotData::new(1);
2674 slots.set(0, SlotValue::Bool(false));
2675
2676 let html = walk_with_slots(
2677 &["visible", "Yes", "No"],
2678 &[(0, 0, 0x02, 0x00, &[])],
2679 &opcodes,
2680 &slots,
2681 );
2682 assert!(html.contains("No"), "should contain else-branch text");
2683 assert!(!html.contains("Yes"), "should NOT contain then-branch text");
2684 assert!(html.contains("<!--f:s0-->"), "should have opening marker");
2685 assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
2686 }
2687
2688 #[test]
2691 fn walk_show_if_truthy_text() {
2692 let then_ops = encode_text(1);
2694 let else_ops = encode_text(2);
2695 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2696
2697 let mut slots = SlotData::new(1);
2698 slots.set(0, SlotValue::Text("hello".to_string()));
2699
2700 let html = walk_with_slots(
2701 &["greeting", "Shown", "Hidden"],
2702 &[(0, 0, 0x01, 0x00, &[])], &opcodes,
2704 &slots,
2705 );
2706 assert!(
2707 html.contains("Shown"),
2708 "truthy text should render then branch"
2709 );
2710 assert!(
2711 !html.contains("Hidden"),
2712 "truthy text should NOT render else branch"
2713 );
2714 }
2715
2716 #[test]
2719 fn walk_show_if_falsy_null() {
2720 let then_ops = encode_text(1);
2722 let else_ops = encode_text(2);
2723 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2724
2725 let slots = SlotData::new(1); let html = walk_with_slots(
2728 &["maybe", "Present", "Missing"],
2729 &[(0, 0, 0x01, 0x00, &[])],
2730 &opcodes,
2731 &slots,
2732 );
2733 assert!(html.contains("Missing"), "null should render else branch");
2734 assert!(
2735 !html.contains("Present"),
2736 "null should NOT render then branch"
2737 );
2738 }
2739
2740 #[test]
2743 fn walk_show_if_no_else() {
2744 let then_ops = encode_text(1);
2746 let else_ops: Vec<u8> = vec![]; let opcodes = encode_show_if(0, &then_ops, &else_ops);
2748
2749 let slots = SlotData::new(1); let html = walk_with_slots(
2752 &["flag", "Content"],
2753 &[(0, 0, 0x02, 0x00, &[])],
2754 &opcodes,
2755 &slots,
2756 );
2757 assert_eq!(
2758 html, "<!--f:s0--><!--/f:s0-->",
2759 "empty else should produce only markers"
2760 );
2761 }
2762
2763 #[test]
2766 fn walk_show_if_nested() {
2767 let inner_then = encode_text(4);
2771 let inner_else = encode_text(5);
2772 let inner_show_if = encode_show_if(1, &inner_then, &inner_else);
2773
2774 let outer_then = inner_show_if; let outer_else = encode_text(3);
2776 let opcodes = encode_show_if(0, &outer_then, &outer_else);
2777
2778 let mut slots = SlotData::new(2);
2779 slots.set(0, SlotValue::Bool(true)); slots.set(1, SlotValue::Bool(false)); let html = walk_with_slots(
2783 &[
2784 "outer",
2785 "inner",
2786 "outer-yes",
2787 "outer-no",
2788 "inner-yes",
2789 "inner-no",
2790 ],
2791 &[(0, 0, 0x02, 0x00, &[]), (1, 1, 0x02, 0x00, &[])],
2792 &opcodes,
2793 &slots,
2794 );
2795 assert!(
2797 html.contains("inner-no"),
2798 "nested: inner false should render inner else"
2799 );
2800 assert!(
2801 !html.contains("inner-yes"),
2802 "nested: inner true branch should not render"
2803 );
2804 assert!(
2805 !html.contains("outer-no"),
2806 "nested: outer else should not render"
2807 );
2808 assert!(
2810 html.contains("<!--f:s0-->"),
2811 "should have outer opening marker"
2812 );
2813 assert!(
2814 html.contains("<!--/f:s0-->"),
2815 "should have outer closing marker"
2816 );
2817 assert!(
2818 html.contains("<!--f:s1-->"),
2819 "should have inner opening marker"
2820 );
2821 assert!(
2822 html.contains("<!--/f:s1-->"),
2823 "should have inner closing marker"
2824 );
2825 }
2826
2827 #[test]
2830 fn walk_show_if_with_elements() {
2831 let mut then_ops = Vec::new();
2834 then_ops.extend_from_slice(&encode_open_tag(1, &[]));
2835 then_ops.extend_from_slice(&encode_text(2));
2836 then_ops.extend_from_slice(&encode_close_tag(1));
2837
2838 let else_ops = encode_text(3);
2839 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2840
2841 let mut slots = SlotData::new(1);
2842 slots.set(0, SlotValue::Bool(true));
2843
2844 let html = walk_with_slots(
2845 &["flag", "span", "Hello", "Bye"],
2846 &[(0, 0, 0x02, 0x00, &[])],
2847 &opcodes,
2848 &slots,
2849 );
2850 assert_eq!(
2851 html, "<!--f:s0--><span>Hello</span><!--/f:s0-->",
2852 "then branch should produce full HTML element"
2853 );
2854 }
2855
2856 #[test]
2859 fn walk_list_renders_items() {
2860 let body = encode_dyn_text(1, 0);
2864 let opcodes = encode_list(0, 1, &body);
2865
2866 let mut slots = SlotData::new(2);
2867 slots.set(
2868 0,
2869 SlotValue::array(vec![
2870 SlotValue::Text("Alice".to_string()),
2871 SlotValue::Text("Bob".to_string()),
2872 ]),
2873 );
2874
2875 let html = walk_with_slots(
2876 &["items", "item"],
2877 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
2879 &slots,
2880 );
2881 assert!(html.contains("Alice"), "should contain first item");
2882 assert!(html.contains("Bob"), "should contain second item");
2883 assert!(
2884 html.starts_with("<!--f:l0-->"),
2885 "should start with list open marker"
2886 );
2887 assert!(
2888 html.ends_with("<!--/f:l0-->"),
2889 "should end with list close marker"
2890 );
2891 }
2892
2893 #[test]
2896 fn walk_list_empty_array() {
2897 let body = encode_dyn_text(1, 0);
2900 let opcodes = encode_list(0, 1, &body);
2901
2902 let mut slots = SlotData::new(2);
2903 slots.set(0, SlotValue::array(vec![]));
2904
2905 let html = walk_with_slots(
2906 &["items", "item"],
2907 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2908 &opcodes,
2909 &slots,
2910 );
2911 assert_eq!(
2912 html, "<!--f:l0--><!--/f:l0-->",
2913 "empty array should produce only markers"
2914 );
2915 }
2916
2917 #[test]
2920 fn walk_list_with_elements() {
2921 let mut body = Vec::new();
2925 body.extend_from_slice(&encode_open_tag(2, &[]));
2926 body.extend_from_slice(&encode_dyn_text(1, 0));
2927 body.extend_from_slice(&encode_close_tag(2));
2928
2929 let opcodes = encode_list(0, 1, &body);
2930
2931 let mut slots = SlotData::new(2);
2932 slots.set(
2933 0,
2934 SlotValue::array(vec![
2935 SlotValue::Text("A".to_string()),
2936 SlotValue::Text("B".to_string()),
2937 ]),
2938 );
2939
2940 let html = walk_with_slots(
2941 &["items", "item", "li"],
2942 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2943 &opcodes,
2944 &slots,
2945 );
2946 assert!(
2947 html.contains("<li><!--f:t0-->A<!--/f:t0--></li><li><!--f:t0-->B<!--/f:t0--></li>"),
2948 "should render li elements with content, got: {html}"
2949 );
2950 }
2951
2952 #[test]
2955 fn walk_list_null_slot() {
2956 let body = encode_dyn_text(1, 0);
2959 let opcodes = encode_list(0, 1, &body);
2960
2961 let slots = SlotData::new(2); let html = walk_with_slots(
2964 &["items", "item"],
2965 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2966 &opcodes,
2967 &slots,
2968 );
2969 assert_eq!(
2970 html, "<!--f:l0--><!--/f:l0-->",
2971 "null slot should produce only markers"
2972 );
2973 }
2974
2975 #[test]
2978 fn walk_list_nested() {
2979 let inner_body = encode_dyn_text(2, 0);
2984 let inner_list = encode_list(1, 2, &inner_body);
2985 let opcodes = encode_list(0, 1, &inner_list);
2986
2987 let mut slots = SlotData::new(3);
2988 slots.set(
2989 0,
2990 SlotValue::array(vec![
2991 SlotValue::array(vec![
2992 SlotValue::Text("a".to_string()),
2993 SlotValue::Text("b".to_string()),
2994 ]),
2995 SlotValue::array(vec![SlotValue::Text("c".to_string())]),
2996 ]),
2997 );
2998
2999 let html = walk_with_slots(
3000 &["outer", "inner", "item"],
3001 &[
3002 (0, 0, 0x04, 0x00, &[]),
3003 (1, 1, 0x04, 0x00, &[]),
3004 (2, 2, 0x01, 0x00, &[]),
3005 ],
3006 &opcodes,
3007 &slots,
3008 );
3009 assert!(html.contains("a"), "should contain nested item 'a'");
3011 assert!(html.contains("b"), "should contain nested item 'b'");
3012 assert!(html.contains("c"), "should contain nested item 'c'");
3013 assert!(
3015 html.starts_with("<!--f:l0-->"),
3016 "should have outer list open marker"
3017 );
3018 assert!(
3019 html.ends_with("<!--/f:l0-->"),
3020 "should have outer list close marker"
3021 );
3022 assert!(
3024 html.contains("<!--f:l1-->"),
3025 "should have inner list open marker"
3026 );
3027 assert!(
3028 html.contains("<!--/f:l1-->"),
3029 "should have inner list close marker"
3030 );
3031 }
3032
3033 #[test]
3036 fn walk_list_depth_exceeded() {
3037 let innermost_body = encode_dyn_text(5, 0);
3041 let level5 = encode_list(4, 5, &innermost_body);
3043 let level4 = encode_list(3, 4, &level5);
3044 let level3 = encode_list(2, 3, &level4);
3045 let level2 = encode_list(1, 2, &level3);
3046 let level1 = encode_list(0, 1, &level2);
3047
3048 let mut slots = SlotData::new(6);
3049 slots.set(
3051 0,
3052 SlotValue::array(vec![SlotValue::array(vec![SlotValue::array(vec![
3053 SlotValue::array(vec![SlotValue::array(vec![SlotValue::Text(
3054 "deep".to_string(),
3055 )])]),
3056 ])])]),
3057 );
3058 let data = build_minimal_ir(
3061 &["s0", "s1", "s2", "s3", "s4", "s5"],
3062 &[
3063 (0, 0, 0x04, 0x00, &[]),
3064 (1, 1, 0x04, 0x00, &[]),
3065 (2, 2, 0x04, 0x00, &[]),
3066 (3, 3, 0x04, 0x00, &[]),
3067 (4, 4, 0x04, 0x00, &[]),
3068 (5, 5, 0x01, 0x00, &[]),
3069 ],
3070 &level1,
3071 &[],
3072 );
3073 let module = IrModule::parse(&data).unwrap();
3074 let result = walk_to_html(&module, &slots);
3075 assert!(result.is_err(), "should fail with depth exceeded");
3076 match result.unwrap_err() {
3077 IrError::ListDepthExceeded { max: 4 } => {}
3078 other => panic!("expected ListDepthExceeded {{ max: 4 }}, got {other:?}"),
3079 }
3080 }
3081
3082 #[test]
3085 fn walk_switch_matching_case() {
3086 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)]);
3092
3093 let mut slots = SlotData::new(1);
3094 slots.set(0, SlotValue::Text("about".to_string()));
3095
3096 let html = walk_with_slots(
3097 &["page", "home", "Home", "about", "About"],
3098 &[(0, 0, 0x01, 0x00, &[])],
3099 &opcodes,
3100 &slots,
3101 );
3102 assert!(html.contains("About"), "should contain matching case text");
3103 assert!(
3104 !html.contains("Home"),
3105 "should NOT contain non-matching case text"
3106 );
3107 }
3108
3109 #[test]
3112 fn walk_switch_no_match() {
3113 let case_home_body = encode_text(2);
3117 let case_about_body = encode_text(4);
3118 let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
3119
3120 let mut slots = SlotData::new(1);
3121 slots.set(0, SlotValue::Text("missing".to_string()));
3122
3123 let html = walk_with_slots(
3124 &["page", "home", "Home", "about", "About"],
3125 &[(0, 0, 0x01, 0x00, &[])],
3126 &opcodes,
3127 &slots,
3128 );
3129 assert!(
3130 html.is_empty(),
3131 "no match should produce empty output, got: {html}"
3132 );
3133 }
3134
3135 #[test]
3138 fn walk_switch_first_case() {
3139 let case_home_body = encode_text(2);
3142 let case_about_body = encode_text(4);
3143 let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
3144
3145 let mut slots = SlotData::new(1);
3146 slots.set(0, SlotValue::Text("home".to_string()));
3147
3148 let html = walk_with_slots(
3149 &["page", "home", "Home", "about", "About"],
3150 &[(0, 0, 0x01, 0x00, &[])],
3151 &opcodes,
3152 &slots,
3153 );
3154 assert!(html.contains("Home"), "should contain first case text");
3155 assert!(
3156 !html.contains("About"),
3157 "should NOT contain second case text"
3158 );
3159 }
3160
3161 #[test]
3162 fn walk_switch_stops_at_the_first_match() {
3163 let opcodes = encode_switch(0, &[(1, &encode_text(2)), (1, &encode_text(3))]);
3166
3167 let mut slots = SlotData::new(1);
3168 slots.set(0, SlotValue::Text("home".to_string()));
3169
3170 let html = walk_with_slots(
3171 &["page", "home", "FIRST", "SECOND"],
3172 &[(0, 0, 0x01, 0x00, &[])],
3173 &opcodes,
3174 &slots,
3175 );
3176 assert_eq!(html, "FIRST");
3177 }
3178
3179 #[test]
3182 fn walk_switch_with_elements() {
3183 let mut active_body = Vec::new();
3186 active_body.extend_from_slice(&encode_open_tag(2, &[]));
3187 active_body.extend_from_slice(&encode_text(3));
3188 active_body.extend_from_slice(&encode_close_tag(2));
3189
3190 let inactive_body = encode_text(5);
3191 let opcodes = encode_switch(0, &[(1, &active_body), (4, &inactive_body)]);
3192
3193 let mut slots = SlotData::new(1);
3194 slots.set(0, SlotValue::Text("active".to_string()));
3195
3196 let html = walk_with_slots(
3197 &["page", "active", "span", "Active", "inactive", "Inactive"],
3198 &[(0, 0, 0x01, 0x00, &[])],
3199 &opcodes,
3200 &slots,
3201 );
3202 assert_eq!(
3203 html, "<span>Active</span>",
3204 "should produce full HTML element from matching case"
3205 );
3206 }
3207
3208 #[test]
3211 fn walk_try_renders_main() {
3212 let main_ops = encode_text(0);
3215 let fallback_ops = encode_text(1);
3216 let opcodes = encode_try(&main_ops, &fallback_ops);
3217
3218 let html = walk_static(&["Main", "Fallback"], &opcodes);
3219 assert!(html.contains("Main"), "should contain main body text");
3220 assert!(
3221 !html.contains("Fallback"),
3222 "should NOT contain fallback text"
3223 );
3224 }
3225
3226 #[test]
3229 fn walk_preload_font() {
3230 let opcodes = encode_preload(1, 0);
3233
3234 let html = walk_static(&["/_assets/dm-mono.woff2"], &opcodes);
3235 assert!(
3236 html.contains(r#"<link rel="preload" href="/_assets/dm-mono.woff2" as="font" type="font/woff2" crossorigin>"#),
3237 "should produce preload link for font, got: {html}"
3238 );
3239 }
3240
3241 #[test]
3244 fn walk_preload_style() {
3245 let opcodes = encode_preload(2, 0);
3248
3249 let html = walk_static(&["/_assets/styles.css"], &opcodes);
3250 assert!(
3251 html.contains(r#"as="style""#),
3252 "should contain as=\"style\", got: {html}"
3253 );
3254 assert!(
3255 html.contains(r#"href="/_assets/styles.css""#),
3256 "should contain correct href, got: {html}"
3257 );
3258 }
3259
3260 #[test]
3270 fn roundtrip_page_structure() {
3271 let mut opcodes = Vec::new();
3279 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(
3293 &[
3294 "html", "lang", "en", "head", "meta", "charset", "utf-8", "title", "Test", "body",
3295 "div", "id", "app",
3296 ],
3297 &[],
3298 &opcodes,
3299 &[],
3300 );
3301 let module = IrModule::parse(&ir).unwrap();
3302 let slots = SlotData::new(0);
3303 let html = walk_to_html(&module, &slots).unwrap();
3304 assert_eq!(
3305 html,
3306 r#"<html lang="en"><head><meta charset="utf-8"><title>Test</title></head><body><div id="app"></div></body></html>"#
3307 );
3308 }
3309
3310 #[test]
3313 fn roundtrip_dynamic_greeting() {
3314 let mut opcodes = Vec::new();
3320 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(
3325 &["div", "class", "greeting", "content"],
3326 &[(0, 3, 0x01, 0x00, &[])], &opcodes,
3328 &[],
3329 );
3330 let module = IrModule::parse(&ir).unwrap();
3331 let mut slots = SlotData::new(1);
3332 slots.set(0, SlotValue::Text("Hello, World".into()));
3333 let html = walk_to_html(&module, &slots).unwrap();
3334 assert_eq!(
3335 html,
3336 r#"<div class="greeting"><!--f:t0-->Hello, World<!--/f:t0--></div>"#
3337 );
3338 }
3339
3340 #[test]
3343 fn roundtrip_conditional_content() {
3344 let mut then_ops = Vec::new();
3351 then_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
3352 then_ops.extend_from_slice(&encode_text(4));
3353 then_ops.extend_from_slice(&encode_close_tag(1));
3354
3355 let mut else_ops = Vec::new();
3356 else_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
3357 else_ops.extend_from_slice(&encode_text(5));
3358 else_ops.extend_from_slice(&encode_close_tag(1));
3359
3360 let opcodes = encode_show_if(0, &then_ops, &else_ops);
3361
3362 let ir = build_minimal_ir(
3363 &["auth", "span", "class", "badge", "Logged In", "Guest"],
3364 &[(0, 0, 0x02, 0x00, &[])], &opcodes,
3366 &[],
3367 );
3368
3369 {
3371 let module = IrModule::parse(&ir).unwrap();
3372 let mut slots = SlotData::new(1);
3373 slots.set(0, SlotValue::Bool(true));
3374 let html = walk_to_html(&module, &slots).unwrap();
3375 assert_eq!(
3376 html,
3377 r#"<!--f:s0--><span class="badge">Logged In</span><!--/f:s0-->"#
3378 );
3379 }
3380
3381 {
3383 let module = IrModule::parse(&ir).unwrap();
3384 let mut slots = SlotData::new(1);
3385 slots.set(0, SlotValue::Bool(false));
3386 let html = walk_to_html(&module, &slots).unwrap();
3387 assert_eq!(
3388 html,
3389 r#"<!--f:s0--><span class="badge">Guest</span><!--/f:s0-->"#
3390 );
3391 }
3392 }
3393
3394 #[test]
3397 fn roundtrip_list_rendering() {
3398 let mut body = Vec::new();
3404 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);
3409
3410 let ir = build_minimal_ir(
3411 &["items", "item", "li"],
3412 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
3414 &[],
3415 );
3416 let module = IrModule::parse(&ir).unwrap();
3417 let mut slots = SlotData::new(2);
3418 slots.set(
3419 0,
3420 SlotValue::array(vec![
3421 SlotValue::Text("Alice".into()),
3422 SlotValue::Text("Bob".into()),
3423 SlotValue::Text("Charlie".into()),
3424 ]),
3425 );
3426 let html = walk_to_html(&module, &slots).unwrap();
3427 assert_eq!(
3428 html,
3429 "<!--f:l0-->\
3430 <li><!--f:t0-->Alice<!--/f:t0--></li>\
3431 <li><!--f:t0-->Bob<!--/f:t0--></li>\
3432 <li><!--f:t0-->Charlie<!--/f:t0--></li>\
3433 <!--/f:l0-->"
3434 );
3435 }
3436
3437 #[test]
3440 fn roundtrip_switch_view() {
3441 let mut home_body = Vec::new();
3446 home_body.extend_from_slice(&encode_open_tag(2, &[]));
3447 home_body.extend_from_slice(&encode_text(3));
3448 home_body.extend_from_slice(&encode_close_tag(2));
3449
3450 let mut about_body = Vec::new();
3451 about_body.extend_from_slice(&encode_open_tag(2, &[]));
3452 about_body.extend_from_slice(&encode_text(5));
3453 about_body.extend_from_slice(&encode_close_tag(2));
3454
3455 let opcodes = encode_switch(0, &[(1, &home_body), (4, &about_body)]);
3456
3457 let ir = build_minimal_ir(
3458 &["page", "home", "h1", "Home", "about", "About"],
3459 &[(0, 0, 0x01, 0x00, &[])], &opcodes,
3461 &[],
3462 );
3463 let module = IrModule::parse(&ir).unwrap();
3464 let mut slots = SlotData::new(1);
3465 slots.set(0, SlotValue::Text("about".into()));
3466 let html = walk_to_html(&module, &slots).unwrap();
3467 assert_eq!(html, "<h1>About</h1>");
3468 }
3469
3470 #[test]
3473 fn roundtrip_nested_structure() {
3474 let mut then_ops = Vec::new();
3495 then_ops.extend_from_slice(&encode_open_tag(4, &[]));
3496 then_ops.extend_from_slice(&encode_text(5));
3497 then_ops.extend_from_slice(&encode_close_tag(4));
3498
3499 let show_if_ops = encode_show_if(0, &then_ops, &[]);
3500
3501 let mut list_body = Vec::new();
3503 list_body.extend_from_slice(&encode_open_tag(9, &[]));
3504 list_body.extend_from_slice(&encode_dyn_text(2, 0));
3505 list_body.extend_from_slice(&encode_close_tag(9));
3506
3507 let list_ops = encode_list(1, 2, &list_body);
3508
3509 let mut opcodes = Vec::new();
3511 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(
3519 &[
3520 "nav", "class", "main-nav", "auth", "span", "Welcome", "items", "item", "ul", "li",
3521 ],
3522 &[
3523 (0, 3, 0x02, 0x00, &[]), (1, 6, 0x04, 0x00, &[]), (2, 7, 0x01, 0x00, &[]), ],
3527 &opcodes,
3528 &[],
3529 );
3530 let module = IrModule::parse(&ir).unwrap();
3531 let mut slots = SlotData::new(3);
3532 slots.set(0, SlotValue::Bool(true));
3533 slots.set(
3534 1,
3535 SlotValue::array(vec![
3536 SlotValue::Text("Home".into()),
3537 SlotValue::Text("About".into()),
3538 ]),
3539 );
3540
3541 let html = walk_to_html(&module, &slots).unwrap();
3542 assert_eq!(
3543 html,
3544 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>"#
3545 );
3546 }
3547
3548 #[test]
3555 fn walk_island_attrs_on_open_tag() {
3556 let mut opcodes = Vec::new();
3560 opcodes.extend_from_slice(&encode_island_start(0));
3561 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3562 opcodes.extend_from_slice(&encode_text(1));
3563 opcodes.extend_from_slice(&encode_close_tag(0));
3564 opcodes.extend_from_slice(&encode_island_end(0));
3565
3566 let slots = SlotData::new(0);
3567 let html = walk_with_islands(
3568 &["div", "Hello", "AuthForm"],
3569 &[],
3570 &[(0, 0x01, 0x01, 2, 0, &[])],
3572 &opcodes,
3573 &slots,
3574 );
3575 assert_eq!(
3576 html,
3577 r#"<!--f:i0--><div data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load">Hello</div><!--/f:i0-->"#
3578 );
3579 }
3580
3581 #[test]
3584 fn walk_island_attrs_with_existing_attrs() {
3585 let mut opcodes = Vec::new();
3588 opcodes.extend_from_slice(&encode_island_start(0));
3589 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
3590 opcodes.extend_from_slice(&encode_close_tag(0));
3591 opcodes.extend_from_slice(&encode_island_end(0));
3592
3593 let slots = SlotData::new(0);
3594 let html = walk_with_islands(
3595 &["div", "class", "form", "AuthForm"],
3596 &[],
3597 &[(0, 0x01, 0x01, 3, 0, &[])],
3598 &opcodes,
3599 &slots,
3600 );
3601 assert_eq!(
3602 html,
3603 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-->"#
3604 );
3605 }
3606
3607 #[test]
3610 fn walk_island_attrs_on_void_tag() {
3611 let mut opcodes = Vec::new();
3614 opcodes.extend_from_slice(&encode_island_start(0));
3615 opcodes.extend_from_slice(&encode_void_tag(0, &[]));
3616 opcodes.extend_from_slice(&encode_island_end(0));
3617
3618 let slots = SlotData::new(0);
3619 let html = walk_with_islands(
3620 &["input", "Widget"],
3621 &[],
3622 &[(0, 0x01, 0x01, 1, 0, &[])],
3623 &opcodes,
3624 &slots,
3625 );
3626 assert_eq!(
3627 html,
3628 r#"<!--f:i0--><input data-forma-island="0" data-forma-component="Widget" data-forma-status="pending" data-forma-hydrate="load"><!--/f:i0-->"#
3629 );
3630 }
3631
3632 #[test]
3635 fn walk_inline_props_attribute() {
3636 let mut opcodes = Vec::new();
3641 opcodes.extend_from_slice(&encode_island_start(0));
3642 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3643 opcodes.extend_from_slice(&encode_close_tag(0));
3644 opcodes.extend_from_slice(&encode_island_end(0));
3645
3646 let mut slots = SlotData::new(2);
3647 slots.set(0, SlotValue::Text("Hello".to_string()));
3648 slots.set(1, SlotValue::Number(42.0));
3649
3650 let html = walk_with_islands(
3651 &["div", "MyComponent", "title", "count"],
3652 &[
3653 (0, 2, 0x01, 0x00, &[]), (1, 3, 0x03, 0x00, &[]), ],
3656 &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
3658 &opcodes,
3659 &slots,
3660 );
3661 assert!(
3663 html.contains("data-forma-props="),
3664 "should contain data-forma-props, got: {html}"
3665 );
3666 assert!(
3667 html.contains(r#"data-forma-island="0""#),
3668 "should contain island id, got: {html}"
3669 );
3670 assert!(
3671 html.contains(r#"data-forma-component="MyComponent""#),
3672 "should contain component name, got: {html}"
3673 );
3674 let marker = "data-forma-props=\"";
3680 let props_start = html.find(marker).unwrap() + marker.len();
3681 let props_end = html[props_start..].find('"').unwrap() + props_start;
3682 let raw_attr = &html[props_start..props_end];
3683 let decoded = raw_attr
3684 .replace(""", "\"")
3685 .replace("&", "&")
3686 .replace("<", "<")
3687 .replace(">", ">");
3688 let props_json: serde_json::Value = serde_json::from_str(&decoded).unwrap();
3689 assert_eq!(props_json["title"], "Hello");
3690 assert_eq!(props_json["count"], 42);
3691 }
3692
3693 #[test]
3696 fn walk_list_item_key_on_first_open_tag() {
3697 let mut opcodes = Vec::new();
3700 opcodes.extend_from_slice(&encode_list_item_key(1));
3701 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3702 opcodes.extend_from_slice(&encode_text(2));
3703 opcodes.extend_from_slice(&encode_close_tag(0));
3704
3705 let html = walk_static(&["li", "user-123", "item"], &opcodes);
3706 assert_eq!(html, r#"<li data-forma-key="user-123">item</li>"#);
3707 }
3708
3709 #[test]
3712 fn walk_list_item_key_on_void_tag() {
3713 let mut opcodes = Vec::new();
3716 opcodes.extend_from_slice(&encode_list_item_key(1));
3717 opcodes.extend_from_slice(&encode_void_tag(0, &[]));
3718
3719 let html = walk_static(&["hr", "key-42"], &opcodes);
3720 assert_eq!(html, r#"<hr data-forma-key="key-42">"#);
3721 }
3722
3723 #[test]
3726 fn walk_list_item_key_with_attrs() {
3727 let mut opcodes = Vec::new();
3730 opcodes.extend_from_slice(&encode_list_item_key(3));
3731 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
3732 opcodes.extend_from_slice(&encode_text(4));
3733 opcodes.extend_from_slice(&encode_close_tag(0));
3734
3735 let html = walk_static(&["li", "class", "item", "k1", "content"], &opcodes);
3736 assert_eq!(html, r#"<li class="item" data-forma-key="k1">content</li>"#);
3737 }
3738
3739 #[test]
3742 fn walk_script_tag_props_mode() {
3743 let mut opcodes = Vec::new();
3747 opcodes.extend_from_slice(&encode_island_start(0));
3748 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3749 opcodes.extend_from_slice(&encode_close_tag(0));
3750 opcodes.extend_from_slice(&encode_island_end(0));
3751
3752 let mut slots = SlotData::new(1);
3753 slots.set(0, SlotValue::Text("Click me".to_string()));
3754
3755 let html = walk_with_islands(
3756 &["div", "Counter", "label"],
3757 &[(0, 2, 0x01, 0x00, &[])], &[(0, 0x01, 0x02, 1, 0, &[0])],
3760 &opcodes,
3761 &slots,
3762 );
3763 assert!(
3765 !html.contains("data-forma-props"),
3766 "ScriptTag mode should not emit inline props, got: {html}"
3767 );
3768 assert!(
3770 html.contains(r#"data-forma-island="0""#),
3771 "should contain island id"
3772 );
3773 assert!(
3775 html.contains(r#"<script id="__forma_islands" type="application/json">"#),
3776 "should contain script tag, got: {html}"
3777 );
3778 assert!(html.contains("</script>"), "should close script tag");
3779 let script_start =
3781 html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
3782 let script_end = html[script_start..].find("</script>").unwrap() + script_start;
3783 let json: serde_json::Value =
3784 serde_json::from_str(&html[script_start..script_end]).unwrap();
3785 assert_eq!(json["0"]["label"], "Click me");
3786 }
3787
3788 #[test]
3791 fn walk_deferred_props_mode() {
3792 let mut opcodes = Vec::new();
3796 opcodes.extend_from_slice(&encode_island_start(0));
3797 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3798 opcodes.extend_from_slice(&encode_close_tag(0));
3799 opcodes.extend_from_slice(&encode_island_end(0));
3800
3801 let mut slots = SlotData::new(1);
3802 slots.set(0, SlotValue::Text("ignored".to_string()));
3803
3804 let html = walk_with_islands(
3805 &["div", "LazyWidget", "data"],
3806 &[(0, 2, 0x01, 0x00, &[])],
3807 &[(0, 0x01, 0x03, 1, 0, &[0])],
3809 &opcodes,
3810 &slots,
3811 );
3812 assert!(
3813 !html.contains("data-forma-props"),
3814 "Deferred mode should not emit inline props, got: {html}"
3815 );
3816 assert!(
3817 !html.contains("__forma_islands"),
3818 "Deferred mode should not emit script tag, got: {html}"
3819 );
3820 assert!(
3822 html.contains(r#"data-forma-island="0""#),
3823 "should contain island id"
3824 );
3825 assert!(
3826 html.contains(r#"data-forma-component="LazyWidget""#),
3827 "should contain component name"
3828 );
3829 }
3830
3831 #[test]
3834 fn walk_island_no_entry_in_table() {
3835 let mut opcodes = Vec::new();
3840 opcodes.extend_from_slice(&encode_island_start(5));
3841 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3842 opcodes.extend_from_slice(&encode_text(1));
3843 opcodes.extend_from_slice(&encode_close_tag(0));
3844 opcodes.extend_from_slice(&encode_island_end(5));
3845
3846 let data = build_minimal_ir(&["div", "Hello"], &[], &opcodes, &[]);
3847 assert_eq!(
3848 IrModule::parse(&data).unwrap_err(),
3849 IrError::UnknownIslandId(5)
3850 );
3851 }
3852
3853 #[test]
3854 fn walk_nested_island_start_before_a_root_element_is_an_error() {
3855 let mut opcodes = Vec::new();
3859 opcodes.extend_from_slice(&encode_island_start(0));
3860 opcodes.extend_from_slice(&encode_island_start(1));
3861 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3862 opcodes.extend_from_slice(&encode_close_tag(0));
3863 opcodes.extend_from_slice(&encode_island_end(1));
3864 opcodes.extend_from_slice(&encode_island_end(0));
3865
3866 let slots = SlotData::new(0);
3867 let data = build_minimal_ir(
3868 &["div", "Outer", "Inner"],
3869 &[],
3870 &opcodes,
3871 &[(0, 0x01, 0x01, 1, 0, &[]), (1, 0x01, 0x01, 2, 3, &[])],
3872 );
3873 let module = IrModule::parse(&data).unwrap();
3874 assert_eq!(
3875 walk_to_html(&module, &slots).unwrap_err(),
3876 IrError::NestedIslandRoot { outer: 0, inner: 1 }
3877 );
3878 }
3879
3880 #[test]
3881 fn walk_island_nested_inside_another_islands_root_is_fine() {
3882 let mut opcodes = Vec::new();
3884 opcodes.extend_from_slice(&encode_island_start(0));
3885 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3886 opcodes.extend_from_slice(&encode_island_start(1));
3887 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3888 opcodes.extend_from_slice(&encode_close_tag(0));
3889 opcodes.extend_from_slice(&encode_island_end(1));
3890 opcodes.extend_from_slice(&encode_close_tag(0));
3891 opcodes.extend_from_slice(&encode_island_end(0));
3892
3893 let slots = SlotData::new(0);
3894 let inner_offset = (encode_island_start(0).len() + encode_open_tag(0, &[]).len()) as u32;
3895 let html = walk_with_islands(
3896 &["div", "Outer", "Inner"],
3897 &[],
3898 &[
3899 (0, 0x01, 0x01, 1, 0, &[]),
3900 (1, 0x01, 0x01, 2, inner_offset, &[]),
3901 ],
3902 &opcodes,
3903 &slots,
3904 );
3905 assert!(html.contains(r#"data-forma-component="Outer""#));
3906 assert!(html.contains(r#"data-forma-component="Inner""#));
3907 }
3908
3909 #[test]
3912 fn walk_inline_props_empty_slots() {
3913 let mut opcodes = Vec::new();
3916 opcodes.extend_from_slice(&encode_island_start(0));
3917 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3918 opcodes.extend_from_slice(&encode_close_tag(0));
3919 opcodes.extend_from_slice(&encode_island_end(0));
3920
3921 let slots = SlotData::new(0);
3922 let html = walk_with_islands(
3923 &["div", "Simple"],
3924 &[],
3925 &[(0, 0x01, 0x01, 1, 0, &[])], &opcodes,
3927 &slots,
3928 );
3929 assert!(
3930 !html.contains("data-forma-props"),
3931 "empty slot_ids should not emit props, got: {html}"
3932 );
3933 assert!(
3934 html.contains(r#"data-forma-island="0""#),
3935 "should contain island id"
3936 );
3937 }
3938
3939 fn walk_island_with_prop_payload(payload: &str) -> String {
3943 let mut opcodes = Vec::new();
3944 opcodes.extend_from_slice(&encode_island_start(0));
3945 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3946 opcodes.extend_from_slice(&encode_close_tag(0));
3947 opcodes.extend_from_slice(&encode_island_end(0));
3948
3949 let mut slots = SlotData::new(1);
3950 slots.set(0, SlotValue::Text(payload.to_string()));
3951 walk_with_islands(
3952 &["div", "Big", "blob"],
3953 &[(0, 2, 0x01, 0x00, &[])],
3954 &[(0, 0x01, 0x01, 1, 0, &[0])],
3955 &opcodes,
3956 &slots,
3957 )
3958 }
3959
3960 #[test]
3961 fn island_props_keep_the_first_slot_for_a_duplicated_name() {
3962 let mut opcodes = Vec::new();
3966 opcodes.extend_from_slice(&encode_island_start(0));
3967 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3968 opcodes.extend_from_slice(&encode_close_tag(0));
3969 opcodes.extend_from_slice(&encode_island_end(0));
3970
3971 let mut slots = SlotData::new(2);
3972 slots.set(0, SlotValue::Text("first".into()));
3973 slots.set(1, SlotValue::Text("second".into()));
3974
3975 let html = walk_with_islands(
3976 &["div", "Comp", "dup"],
3977 &[(0, 2, 0x01, 0x00, &[]), (1, 2, 0x01, 0x00, &[])],
3978 &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
3979 &opcodes,
3980 &slots,
3981 );
3982 assert!(
3983 html.contains(r#"data-forma-props="{"dup":"first"}""#),
3984 "expected the first slot to win, got: {html}"
3985 );
3986 }
3987
3988 #[test]
3989 fn inline_props_under_the_ceiling_stay_inline() {
3990 let html = walk_island_with_prop_payload("small");
3991 assert!(html.contains(r#"data-forma-props="{"blob":"small"}""#));
3992 assert!(!html.contains("__forma_islands"));
3993 }
3994
3995 #[test]
3996 fn inline_props_over_the_ceiling_spill_to_the_shared_block() {
3997 let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
4003 let html = walk_island_with_prop_payload(&payload);
4004 assert!(
4005 !html.contains("data-forma-props"),
4006 "over-size props must not stay in the attribute"
4007 );
4008 assert!(
4009 html.contains(r#"<script id="__forma_islands" type="application/json">"#),
4010 "over-size props must land in the shared block, got: {}",
4011 &html[..html.len().min(200)]
4012 );
4013 assert!(html.contains(&payload), "the payload itself must survive");
4014 }
4015
4016 #[test]
4017 fn island_fragment_keeps_over_size_props_inline() {
4018 let mut opcodes = Vec::new();
4022 opcodes.extend_from_slice(&encode_island_start(0));
4023 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4024 opcodes.extend_from_slice(&encode_close_tag(0));
4025 opcodes.extend_from_slice(&encode_island_end(0));
4026
4027 let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
4028 let mut slots = SlotData::new(1);
4029 slots.set(0, SlotValue::Text(payload));
4030
4031 let data = build_minimal_ir(
4032 &["div", "Big", "blob"],
4033 &[(0, 2, 0x01, 0x00, &[])],
4034 &opcodes,
4035 &[(0, 0x01, 0x01, 1, 0, &[0])],
4036 );
4037 let module = IrModule::parse(&data).unwrap();
4038 let fragment = walk_island(&module, &slots, 0).unwrap();
4039 assert!(fragment.contains("data-forma-props"));
4040 assert!(!fragment.contains("__forma_islands"));
4041 }
4042
4043 #[test]
4046 fn walk_island_and_list_key_combined() {
4047 let mut opcodes = Vec::new();
4051 opcodes.extend_from_slice(&encode_island_start(0));
4052 opcodes.extend_from_slice(&encode_list_item_key(2));
4053 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4054 opcodes.extend_from_slice(&encode_close_tag(0));
4055 opcodes.extend_from_slice(&encode_island_end(0));
4056
4057 let slots = SlotData::new(0);
4058 let html = walk_with_islands(
4059 &["div", "Card", "item-1"],
4060 &[],
4061 &[(0, 0x01, 0x01, 1, 0, &[])],
4062 &opcodes,
4063 &slots,
4064 );
4065 assert!(
4066 html.contains(r#"data-forma-island="0""#),
4067 "should contain island attrs"
4068 );
4069 assert!(
4070 html.contains(r#"data-forma-key="item-1""#),
4071 "should contain list key attr, got: {html}"
4072 );
4073 }
4074
4075 #[test]
4078 fn walk_multiple_islands_script_tag() {
4079 let mut opcodes = Vec::new();
4083 opcodes.extend_from_slice(&encode_island_start(0));
4085 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4086 opcodes.extend_from_slice(&encode_close_tag(0));
4087 opcodes.extend_from_slice(&encode_island_end(0));
4088 let island1_offset = opcodes.len() as u32;
4090 opcodes.extend_from_slice(&encode_island_start(1));
4091 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4092 opcodes.extend_from_slice(&encode_close_tag(1));
4093 opcodes.extend_from_slice(&encode_island_end(1));
4094
4095 let mut slots = SlotData::new(2);
4096 slots.set(0, SlotValue::Text("hello".to_string()));
4097 slots.set(1, SlotValue::Bool(true));
4098
4099 let html = walk_with_islands(
4100 &["div", "span", "CompA", "CompB", "msg", "flag"],
4101 &[
4102 (0, 4, 0x01, 0x00, &[]), (1, 5, 0x02, 0x00, &[]), ],
4105 &[
4106 (0, 0x01, 0x02, 2, 0, &[0]), (1, 0x01, 0x02, 3, island1_offset, &[1]), ],
4109 &opcodes,
4110 &slots,
4111 );
4112 let script_start =
4114 html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
4115 let script_end = html[script_start..].find("</script>").unwrap() + script_start;
4116 let json: serde_json::Value =
4117 serde_json::from_str(&html[script_start..script_end]).unwrap();
4118 assert_eq!(json["0"]["msg"], "hello");
4119 assert_eq!(json["1"]["flag"], true);
4120 }
4121
4122 #[test]
4125 fn walk_list_item_key_consumed_once() {
4126 let mut opcodes = Vec::new();
4130 opcodes.extend_from_slice(&encode_list_item_key(2));
4131 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4132 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4133 opcodes.extend_from_slice(&encode_text(3));
4134 opcodes.extend_from_slice(&encode_close_tag(1));
4135 opcodes.extend_from_slice(&encode_close_tag(0));
4136
4137 let html = walk_static(&["li", "span", "abc", "text"], &opcodes);
4138 assert_eq!(html, r#"<li data-forma-key="abc"><span>text</span></li>"#);
4139 }
4140
4141 #[test]
4144 fn slot_value_to_json_all_types() {
4145 assert_eq!(SlotValue::Null.to_json(), serde_json::Value::Null);
4147 assert_eq!(
4148 SlotValue::Text("hello".to_string()).to_json(),
4149 serde_json::Value::String("hello".to_string())
4150 );
4151 assert_eq!(
4152 SlotValue::Bool(true).to_json(),
4153 serde_json::Value::Bool(true)
4154 );
4155 assert_eq!(
4156 SlotValue::Bool(false).to_json(),
4157 serde_json::Value::Bool(false)
4158 );
4159 assert_eq!(SlotValue::Number(42.0).to_json(), serde_json::json!(42));
4160 assert_eq!(SlotValue::Number(3.15).to_json(), serde_json::json!(3.15));
4161 assert_eq!(
4162 SlotValue::array(vec![
4163 SlotValue::Text("a".to_string()),
4164 SlotValue::Number(1.0)
4165 ])
4166 .to_json(),
4167 serde_json::json!(["a", 1])
4168 );
4169 assert_eq!(
4170 SlotValue::object(vec![(
4171 "key".to_string(),
4172 SlotValue::Text("val".to_string())
4173 ),])
4174 .to_json(),
4175 serde_json::json!({"key": "val"})
4176 );
4177 }
4178
4179 #[test]
4182 fn dyn_attr_emits_attribute_with_value() {
4183 let mut opcodes = Vec::new();
4187 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4188 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4189 opcodes.extend_from_slice(&encode_close_tag(0));
4190
4191 let mut slots = SlotData::new(1);
4192 slots.set(0, SlotValue::Text("password".to_string()));
4193
4194 let html = walk_with_slots(
4195 &["input", "type", "input_type"],
4196 &[(0, 2, 0x01, 0x01, b"password")], &opcodes,
4198 &slots,
4199 );
4200 assert_eq!(html, r#"<input type="password"></input>"#);
4201 }
4202
4203 #[test]
4206 fn dyn_attr_skips_when_slot_null() {
4207 let mut opcodes = Vec::new();
4211 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4212 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4213 opcodes.extend_from_slice(&encode_close_tag(0));
4214
4215 let slots = SlotData::new(1); let html = walk_with_slots(
4218 &["div", "class", "cls"],
4219 &[(0, 2, 0x01, 0x00, &[])],
4220 &opcodes,
4221 &slots,
4222 );
4223 assert_eq!(html, "<div></div>");
4224 }
4225
4226 #[test]
4229 fn dyn_attr_multiple_on_same_element() {
4230 let mut opcodes = Vec::new();
4233 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4234 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4235 opcodes.extend_from_slice(&encode_dyn_attr(2, 1));
4236 opcodes.extend_from_slice(&encode_close_tag(0));
4237
4238 let mut slots = SlotData::new(2);
4239 slots.set(0, SlotValue::Text("email".to_string()));
4240 slots.set(1, SlotValue::Text("form-input".to_string()));
4241
4242 let html = walk_with_slots(
4243 &["input", "type", "class", "slot_type", "slot_class"],
4244 &[(0, 3, 0x01, 0x01, b"text"), (1, 4, 0x01, 0x01, b"")],
4245 &opcodes,
4246 &slots,
4247 );
4248 assert_eq!(html, r#"<input type="email" class="form-input"></input>"#);
4249 }
4250
4251 #[test]
4254 fn dyn_attr_on_void_tag() {
4255 let mut opcodes = Vec::new();
4259 opcodes.extend_from_slice(&encode_void_tag(0, &[]));
4260 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4261
4262 let mut slots = SlotData::new(1);
4263 slots.set(0, SlotValue::Text("checkbox".to_string()));
4264
4265 let html = walk_with_slots(
4266 &["input", "type", "slot_type"],
4267 &[(0, 2, 0x01, 0x01, b"text")],
4268 &opcodes,
4269 &slots,
4270 );
4271 assert_eq!(html, r#"<input type="checkbox">"#);
4272 }
4273
4274 #[test]
4277 fn walk_island_basic() {
4278 let pre_island = encode_open_tag(1, &[]); let island_start = encode_island_start(0);
4293 let open_p = encode_open_tag(2, &[]); let text_hello = encode_text(3);
4295 let close_p = encode_close_tag(2);
4296 let island_end = encode_island_end(0);
4297 let close_div = encode_close_tag(1);
4298
4299 let byte_offset = pre_island.len() as u32; let mut opcodes = Vec::new();
4302 opcodes.extend_from_slice(&pre_island);
4303 opcodes.extend_from_slice(&island_start);
4304 opcodes.extend_from_slice(&open_p);
4305 opcodes.extend_from_slice(&text_hello);
4306 opcodes.extend_from_slice(&close_p);
4307 opcodes.extend_from_slice(&island_end);
4308 opcodes.extend_from_slice(&close_div);
4309
4310 let data = build_minimal_ir(
4311 &["MyIsland", "div", "p", "hello"],
4312 &[],
4313 &opcodes,
4314 &[(0, 0x01, 0x01, 0, byte_offset, &[])], );
4316 let module = IrModule::parse(&data).unwrap();
4317 let slots = SlotData::new(0);
4318
4319 let html = walk_island(&module, &slots, 0).unwrap();
4320 assert_eq!(
4324 html,
4325 "<p data-forma-island=\"0\" data-forma-component=\"MyIsland\" \
4326 data-forma-status=\"pending\" data-forma-hydrate=\"load\">hello</p>"
4327 );
4328 assert!(!html.contains("<!--f:i0-->"));
4331 assert!(!html.contains("<!--/f:i0-->"));
4332 }
4333
4334 #[test]
4335 fn walk_island_attrs_match_walk_to_html() {
4336 let island_start = encode_island_start(0);
4339 let open_div = encode_open_tag(1, &[]);
4340 let dyn_text = encode_dyn_text(0, 0);
4341 let close_div = encode_close_tag(1);
4342 let island_end = encode_island_end(0);
4343
4344 let mut opcodes = Vec::new();
4345 opcodes.extend_from_slice(&island_start);
4346 opcodes.extend_from_slice(&open_div);
4347 opcodes.extend_from_slice(&dyn_text);
4348 opcodes.extend_from_slice(&close_div);
4349 opcodes.extend_from_slice(&island_end);
4350
4351 let data = build_minimal_ir(
4352 &["IslandComp", "div", "greeting"],
4353 &[(0, 2, 0x01, 0x00, &[])],
4354 &opcodes,
4355 &[(0, 0x02, 0x01, 0, 0, &[0])], );
4357 let module = IrModule::parse(&data).unwrap();
4358 let mut slots = SlotData::new(1);
4359 slots.set(0, SlotValue::Text("World".to_string()));
4360
4361 let page = walk_to_html(&module, &slots).unwrap();
4362 let fragment = walk_island(&module, &slots, 0).unwrap();
4363
4364 for attr in [
4365 "data-forma-island=\"0\"",
4366 "data-forma-component=\"IslandComp\"",
4367 "data-forma-status=\"pending\"",
4368 "data-forma-hydrate=\"visible\"",
4369 "data-forma-props=",
4370 ] {
4371 assert!(page.contains(attr), "page missing {attr}: {page}");
4372 assert!(
4373 fragment.contains(attr),
4374 "fragment missing {attr}: {fragment}"
4375 );
4376 }
4377 }
4378
4379 #[test]
4380 fn walk_island_script_tag_mode_inlines_props_in_fragment() {
4381 let mut opcodes = Vec::new();
4387 opcodes.extend_from_slice(&encode_island_start(0));
4388 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4389 opcodes.extend_from_slice(&encode_close_tag(1));
4390 opcodes.extend_from_slice(&encode_island_end(0));
4391
4392 let data = build_minimal_ir(
4393 &["IslandComp", "div", "greeting"],
4394 &[(0, 2, 0x01, 0x00, &[])],
4395 &opcodes,
4396 &[(0, 0x01, 0x02, 0, 0, &[0])], );
4398 let module = IrModule::parse(&data).unwrap();
4399 let mut slots = SlotData::new(1);
4400 slots.set(0, SlotValue::Text("World".to_string()));
4401
4402 let fragment = walk_island(&module, &slots, 0).unwrap();
4403 assert!(
4404 fragment.contains("data-forma-props="),
4405 "ScriptTag island fragment must inline its props: {fragment}"
4406 );
4407 assert!(
4408 !fragment.contains("__forma_islands"),
4409 "fragment must not emit a second islands block: {fragment}"
4410 );
4411 }
4412
4413 #[test]
4414 fn walk_island_nested_script_tag_island_keeps_its_props() {
4415 let mut opcodes = encode_island_start(0);
4419 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4420 let inner_offset = opcodes.len() as u32;
4421 opcodes.extend_from_slice(&encode_island_start(1));
4422 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4423 opcodes.extend_from_slice(&encode_close_tag(1));
4424 opcodes.extend_from_slice(&encode_island_end(1));
4425 opcodes.extend_from_slice(&encode_close_tag(1));
4426 opcodes.extend_from_slice(&encode_island_end(0));
4427
4428 let data = build_minimal_ir(
4429 &["Outer", "div", "greeting", "Inner"],
4430 &[(0, 2, 0x01, 0x00, &[])],
4431 &opcodes,
4432 &[
4433 (0, 0x01, 0x01, 0, 0, &[]),
4434 (1, 0x01, 0x02, 3, inner_offset, &[0]), ],
4436 );
4437 let module = IrModule::parse(&data).unwrap();
4438 let mut slots = SlotData::new(1);
4439 slots.set(0, SlotValue::Text("World".into()));
4440
4441 let fragment = walk_island(&module, &slots, 0).unwrap();
4442 assert!(
4443 fragment.contains("data-forma-component=\"Inner\""),
4444 "nested island must render: {fragment}"
4445 );
4446 assert!(
4447 fragment.contains("data-forma-props="),
4448 "nested ScriptTag island must keep its props inline: {fragment}"
4449 );
4450 assert!(!fragment.contains("__forma_islands"), "{fragment}");
4451 }
4452
4453 #[test]
4454 fn walk_island_not_found() {
4455 let opcodes = encode_text(0);
4457 let data = build_minimal_ir(&["Hello"], &[], &opcodes, &[]);
4458 let module = IrModule::parse(&data).unwrap();
4459 let slots = SlotData::new(0);
4460
4461 let result = walk_island(&module, &slots, 5);
4462 assert!(result.is_err());
4463 match result.unwrap_err() {
4464 IrError::IslandNotFound(5) => {} other => panic!("expected IslandNotFound(5), got {other:?}"),
4466 }
4467 }
4468
4469 #[test]
4470 fn walk_island_with_dyn_text() {
4471 let island_start = encode_island_start(0);
4476 let open_div = encode_open_tag(1, &[]); let dyn_text = encode_dyn_text(0, 0); let close_div = encode_close_tag(1);
4479 let island_end = encode_island_end(0);
4480
4481 let byte_offset = 0u32; let mut opcodes = Vec::new();
4484 opcodes.extend_from_slice(&island_start);
4485 opcodes.extend_from_slice(&open_div);
4486 opcodes.extend_from_slice(&dyn_text);
4487 opcodes.extend_from_slice(&close_div);
4488 opcodes.extend_from_slice(&island_end);
4489
4490 let data = build_minimal_ir(
4491 &["IslandComp", "div", "greeting"],
4492 &[(0, 2, 0x01, 0x00, &[])], &opcodes,
4494 &[(0, 0x01, 0x01, 0, byte_offset, &[0])], );
4496 let module = IrModule::parse(&data).unwrap();
4497
4498 let mut slots = SlotData::new(1);
4499 slots.set(0, SlotValue::Text("World".to_string()));
4500
4501 let html = walk_island(&module, &slots, 0).unwrap();
4502 assert_eq!(
4504 html,
4505 "<div data-forma-island=\"0\" data-forma-component=\"IslandComp\" \
4506 data-forma-status=\"pending\" data-forma-hydrate=\"load\" \
4507 data-forma-props=\"{"greeting":"World"}\">\
4508 <!--f:t0-->World<!--/f:t0--></div>"
4509 );
4510 }
4511
4512 #[test]
4513 fn walk_island_byte_offset_out_of_bounds() {
4514 let opcodes = encode_text(0); let data = build_minimal_ir(
4522 &["BadIsland", "hello"],
4523 &[],
4524 &opcodes,
4525 &[(0, 0x01, 0x01, 0, 9999, &[])], );
4527 let err = IrModule::parse(&data).unwrap_err();
4528 assert_eq!(
4529 err,
4530 IrError::IslandOffsetMismatch {
4531 island_id: 0,
4532 byte_offset: 9999
4533 }
4534 );
4535 }
4536
4537 fn encode_prop(src_slot_id: u16, prop_str_idx: u32, target_slot_id: u16) -> Vec<u8> {
4541 let mut buf = vec![0x12]; buf.extend_from_slice(&src_slot_id.to_le_bytes());
4543 buf.extend_from_slice(&prop_str_idx.to_le_bytes());
4544 buf.extend_from_slice(&target_slot_id.to_le_bytes());
4545 buf
4546 }
4547
4548 #[test]
4551 fn walk_prop_in_list_body() {
4552 let mut body = Vec::new();
4559 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);
4562
4563 let mut slots = SlotData::new(3);
4564 slots.set(
4565 0,
4566 SlotValue::array(vec![
4567 SlotValue::object(vec![(
4568 "name".to_string(),
4569 SlotValue::Text("Alice".to_string()),
4570 )]),
4571 SlotValue::object(vec![(
4572 "name".to_string(),
4573 SlotValue::Text("Bob".to_string()),
4574 )]),
4575 ]),
4576 );
4577
4578 let html = walk_with_slots(
4579 &["items", "item", "name"],
4580 &[
4581 (0, 0, 0x04, 0x00, &[]), (1, 1, 0x05, 0x00, &[]), (2, 2, 0x01, 0x00, &[]), ],
4585 &opcodes,
4586 &slots,
4587 );
4588 assert!(html.contains("Alice"), "should contain Alice, got: {html}");
4589 assert!(html.contains("Bob"), "should contain Bob, got: {html}");
4590 }
4591
4592 #[test]
4595 fn walk_prop_multiple_properties() {
4596 let mut body = Vec::new();
4603 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);
4614
4615 let mut slots = SlotData::new(4);
4616 slots.set(
4617 0,
4618 SlotValue::array(vec![
4619 SlotValue::object(vec![
4620 ("name".to_string(), SlotValue::Text("Alice".to_string())),
4621 (
4622 "email".to_string(),
4623 SlotValue::Text("alice@test.com".to_string()),
4624 ),
4625 ]),
4626 SlotValue::object(vec![
4627 ("name".to_string(), SlotValue::Text("Bob".to_string())),
4628 (
4629 "email".to_string(),
4630 SlotValue::Text("bob@test.com".to_string()),
4631 ),
4632 ]),
4633 ]),
4634 );
4635
4636 let html = walk_with_slots(
4637 &["rows", "row", "name", "email", "tr", "td"],
4638 &[
4639 (0, 0, 0x04, 0x00, &[]),
4640 (1, 1, 0x05, 0x00, &[]),
4641 (2, 2, 0x01, 0x00, &[]),
4642 (3, 3, 0x01, 0x00, &[]),
4643 ],
4644 &opcodes,
4645 &slots,
4646 );
4647 assert!(
4648 html.contains("<td><!--f:t0-->Alice<!--/f:t0--></td>"),
4649 "should contain Alice td, got: {html}"
4650 );
4651 assert!(
4652 html.contains("<td><!--f:t1-->alice@test.com<!--/f:t1--></td>"),
4653 "should contain alice email, got: {html}"
4654 );
4655 assert!(
4656 html.contains("<td><!--f:t0-->Bob<!--/f:t0--></td>"),
4657 "should contain Bob td, got: {html}"
4658 );
4659 assert!(
4660 html.contains("<td><!--f:t1-->bob@test.com<!--/f:t1--></td>"),
4661 "should contain bob email, got: {html}"
4662 );
4663 }
4664
4665 #[test]
4668 fn walk_prop_on_non_object() {
4669 let mut opcodes = Vec::new();
4672 opcodes.extend_from_slice(&encode_prop(0, 1, 1));
4673 opcodes.extend_from_slice(&encode_dyn_text(1, 0));
4674
4675 let mut slots = SlotData::new(2);
4676 slots.set(0, SlotValue::Text("hello".to_string()));
4677
4678 let html = walk_with_slots(
4679 &["src", "name"],
4680 &[(0, 0, 0x01, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4681 &opcodes,
4682 &slots,
4683 );
4684 assert!(
4686 html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4687 "non-object PROP should produce empty text, got: {html}"
4688 );
4689 }
4690
4691 #[test]
4694 fn walk_prop_missing_property() {
4695 let mut opcodes = Vec::new();
4698 opcodes.extend_from_slice(&encode_prop(0, 2, 1));
4699 opcodes.extend_from_slice(&encode_dyn_text(1, 0));
4700
4701 let mut slots = SlotData::new(2);
4702 slots.set(
4703 0,
4704 SlotValue::object(vec![(
4705 "name".to_string(),
4706 SlotValue::Text("Alice".to_string()),
4707 )]),
4708 );
4709
4710 let html = walk_with_slots(
4711 &["src", "target", "missing"],
4712 &[(0, 0, 0x05, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4713 &opcodes,
4714 &slots,
4715 );
4716 assert!(
4717 html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4718 "missing property should produce empty text, got: {html}"
4719 );
4720 }
4721
4722 #[test]
4725 fn walk_nested_show_if_within_limit() {
4726 let inner = encode_text(5);
4730 let level4 = encode_show_if(4, &inner, &[]);
4731 let level3 = encode_show_if(3, &level4, &[]);
4732 let level2 = encode_show_if(2, &level3, &[]);
4733 let level1 = encode_show_if(1, &level2, &[]);
4734 let level0 = encode_show_if(0, &level1, &[]);
4735
4736 let mut slots = SlotData::new(5);
4737 for i in 0..5 {
4738 slots.set(i, SlotValue::Bool(true));
4739 }
4740
4741 let html = walk_with_slots(
4742 &["s0", "s1", "s2", "s3", "s4", "deep"],
4743 &[
4744 (0, 0, 0x02, 0x00, &[]),
4745 (1, 1, 0x02, 0x00, &[]),
4746 (2, 2, 0x02, 0x00, &[]),
4747 (3, 3, 0x02, 0x00, &[]),
4748 (4, 4, 0x02, 0x00, &[]),
4749 ],
4750 &level0,
4751 &slots,
4752 );
4753 assert!(
4754 html.contains("deep"),
4755 "5 levels of nesting should succeed, got: {html}"
4756 );
4757 }
4758
4759 fn script_tag_props_html(payload: &str) -> (String, String) {
4762 let mut opcodes = Vec::new();
4765 opcodes.extend_from_slice(&encode_island_start(0));
4766 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4767 opcodes.extend_from_slice(&encode_close_tag(0));
4768 opcodes.extend_from_slice(&encode_island_end(0));
4769
4770 let mut slots = SlotData::new(1);
4771 slots.set(0, SlotValue::Text(payload.to_string()));
4772
4773 let html = walk_with_islands(
4774 &["div", "Comp", "payload"],
4775 &[(0, 2, 0x01, 0x00, &[])],
4776 &[(0, 0x01, 0x02, 1, 0, &[0])],
4777 &opcodes,
4778 &slots,
4779 );
4780
4781 const OPEN: &str = "type=\"application/json\">";
4782 let start = html.find(OPEN).expect("islands script block") + OPEN.len();
4783 let end = html[start..].find("</script>").expect("script close") + start;
4784 let body = html[start..end].to_string();
4785 (html, body)
4786 }
4787
4788 #[test]
4789 fn walk_script_tag_props_body_has_no_raw_angle_brackets() {
4790 let (_html, body) = script_tag_props_html("</script>alert(1)");
4795 assert!(
4796 !body.contains('<') && !body.contains('>'),
4797 "islands block must contain no raw angle brackets, got: {body}"
4798 );
4799 assert!(body.contains("\\u003c/script\\u003e"), "got: {body}");
4800
4801 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4805 assert_eq!(parsed["0"]["payload"], "</script>alert(1)");
4806 }
4807
4808 #[test]
4809 fn walk_script_tag_props_escapes_comment_script_breakout() {
4810 let payload = "<!--<script>PAYLOAD";
4816 let (html, body) = script_tag_props_html(payload);
4817
4818 assert!(
4819 !html.contains("<!--<script"),
4820 "must not emit the script-data-double-escape trigger, got: {html}"
4821 );
4822 assert!(!body.contains('<') && !body.contains('>'), "got: {body}");
4823
4824 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4825 assert_eq!(parsed["0"]["payload"], payload);
4826 }
4827
4828 #[test]
4829 #[ignore = "requires admin/dist IR files — run in monorepo or after `npm run build`"]
4830 fn walk_real_benchmark_ir() {
4831 let dist_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../admin/dist");
4833 let ir_path = std::fs::read_dir(&dist_dir)
4834 .expect("admin/dist must exist")
4835 .filter_map(|e| e.ok())
4836 .find(|e| {
4837 let name = e.file_name();
4838 let s = name.to_string_lossy();
4839 s.starts_with("platform-benchmark.") && s.ends_with(".ir")
4840 })
4841 .map(|e| e.path())
4842 .expect("platform-benchmark.*.ir must exist in admin/dist");
4843 let data = std::fs::read(&ir_path).expect("benchmark IR file must exist");
4844 let module = IrModule::parse(&data).expect("IR must parse");
4845
4846 let slots = SlotData::new_from_defaults(&module.slots);
4848 let html = walk_to_html(&module, &slots).expect("walk must succeed");
4849
4850 assert!(html.contains("<!--f:i0-->"), "missing island 0 start");
4852 assert!(html.contains("<!--/f:i0-->"), "missing island 0 end");
4853 assert!(
4854 html.contains("data-forma-island=\"0\""),
4855 "missing island 0 attr"
4856 );
4857
4858 assert!(
4861 html.contains("Search"),
4862 "FilterBar must contain 'Search' label text in SSR"
4863 );
4864 assert!(
4865 html.contains("filter-group"),
4866 "FilterBar must contain filter-group divs in SSR"
4867 );
4868 assert!(
4869 html.contains("Sort by"),
4870 "FilterBar must contain 'Sort by' label text in SSR"
4871 );
4872
4873 assert!(
4875 html.contains("Performance"),
4876 "PerfPanel must contain 'Performance' heading in SSR"
4877 );
4878
4879 assert!(
4882 html.contains("benchmark-data-table"),
4883 "BenchmarkDataTable root class must exist"
4884 );
4885
4886 let start = html
4890 .find("<!--f:i0-->")
4891 .expect("FilterBar island start marker");
4892 let end = html
4893 .find("<!--/f:i0-->")
4894 .expect("FilterBar island end marker");
4895 assert!(end > start, "island end marker must follow its start");
4896 }
4897
4898 #[test]
4901 fn walk_output_limit_trips_inside_one_opcode() {
4902 let big = "&".repeat(4096); let attrs: Vec<(u32, u32)> = (0..200).map(|_| (1u32, 2u32)).collect();
4908 let opcodes = encode_open_tag(0, &attrs);
4909
4910 let data = build_minimal_ir(&["div", "data-x", &big], &[], &opcodes, &[]);
4911 let module = IrModule::parse(&data).unwrap();
4912 let slots = SlotData::new(0);
4913
4914 let limits = WalkLimits {
4915 max_output_bytes: 64 * 1024,
4916 ..WalkLimits::default()
4917 };
4918 let err = walk_to_html_with_limits(&module, &slots, limits).unwrap_err();
4919 assert_eq!(err, IrError::OutputLimitExceeded { limit: 64 * 1024 });
4920
4921 let ok = WalkLimits {
4923 max_output_bytes: 64 * 1024 * 1024,
4924 ..WalkLimits::default()
4925 };
4926 assert!(walk_to_html_with_limits(&module, &slots, ok).is_ok());
4927 }
4928
4929 #[test]
4930 fn walk_output_limit_trips_on_a_single_dyn_text_value() {
4931 let opcodes = encode_dyn_text(0, 0);
4933 let data = build_minimal_ir(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &[]);
4934 let module = IrModule::parse(&data).unwrap();
4935
4936 let mut slots = SlotData::new(1);
4937 slots.set(0, SlotValue::Text("x".repeat(100_000)));
4938
4939 let limits = WalkLimits {
4940 max_output_bytes: 4096,
4941 ..WalkLimits::default()
4942 };
4943 assert!(matches!(
4944 walk_to_html_with_limits(&module, &slots, limits),
4945 Err(IrError::OutputLimitExceeded { .. })
4946 ));
4947 }
4948
4949 #[test]
4952 fn walk_list_budget_exceeded() {
4953 let body = encode_text(2);
4956 let inner = encode_list(0, 1, &body);
4957 let opcodes = encode_list(0, 1, &inner);
4958
4959 let data = build_minimal_ir(
4960 &["items", "item", "x"],
4961 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4962 &opcodes,
4963 &[],
4964 );
4965 let module = IrModule::parse(&data).unwrap();
4966
4967 let mut slots = SlotData::new(2);
4968 slots.set(
4969 0,
4970 SlotValue::array((0..300).map(|_| SlotValue::Text("a".into())).collect()),
4971 );
4972
4973 let err = walk_to_html(&module, &slots).unwrap_err();
4974 assert_eq!(
4975 err,
4976 IrError::ListBudgetExceeded {
4977 max: DEFAULT_MAX_LIST_ITERATIONS
4978 }
4979 );
4980 assert_ne!(
4984 err,
4985 IrError::ListDepthExceeded {
4986 max: MAX_LIST_DEPTH
4987 }
4988 );
4989 }
4990
4991 fn patch_u32(buf: &mut [u8], at: usize, value: u32) {
4995 buf[at..at + 4].copy_from_slice(&value.to_le_bytes());
4996 }
4997
4998 #[test]
4999 fn parse_rejects_show_if_then_len_past_the_stream() {
5000 let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
5002 patch_u32(&mut opcodes, 3, 0xFFFF_FF00); let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
5004 assert!(matches!(
5005 IrModule::parse(&data),
5006 Err(IrError::MalformedControlFlow { .. })
5007 ));
5008 }
5009
5010 #[test]
5011 fn parse_rejects_show_if_marker_byte_that_is_not_show_else() {
5012 let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
5015 patch_u32(&mut opcodes, 3, 4); let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
5017 assert!(matches!(
5018 IrModule::parse(&data),
5019 Err(IrError::MalformedControlFlow { .. })
5020 ));
5021 }
5022
5023 #[test]
5024 fn parse_rejects_list_body_len_past_the_stream() {
5025 let mut opcodes = encode_list(0, 1, &encode_text(2));
5027 patch_u32(&mut opcodes, 5, 0xFFFF_FF00);
5028 let data = build_minimal_ir(
5029 &["items", "item", "x"],
5030 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
5031 &opcodes,
5032 &[],
5033 );
5034 assert!(matches!(
5035 IrModule::parse(&data),
5036 Err(IrError::MalformedControlFlow { .. })
5037 ));
5038 }
5039
5040 #[test]
5041 fn parse_rejects_switch_case_body_len_past_the_stream() {
5042 let mut opcodes = encode_switch(0, &[(1, &encode_text(2)), (3, &encode_text(2))]);
5045 patch_u32(&mut opcodes, 9, 0xFFFF_FF00);
5047 let data = build_minimal_ir(
5048 &["mode", "a", "x", "b"],
5049 &[(0, 0, 0x01, 0x00, &[])],
5050 &opcodes,
5051 &[],
5052 );
5053 assert!(matches!(
5054 IrModule::parse(&data),
5055 Err(IrError::MalformedControlFlow { .. })
5056 ));
5057 }
5058
5059 #[test]
5060 fn parse_rejects_fallback_skip_past_the_stream() {
5061 let mut opcodes = encode_try(&encode_text(0), &encode_text(0));
5063 patch_u32(&mut opcodes, 1, 0xFFFF_FF00);
5064 let data = build_minimal_ir(&["hi"], &[], &opcodes, &[]);
5065 assert!(matches!(
5066 IrModule::parse(&data),
5067 Err(IrError::MalformedControlFlow { .. })
5068 ));
5069 }
5070
5071 #[test]
5072 fn parse_accepts_zero_length_else_branch_known_residual() {
5073 let opcodes = encode_show_if(0, &encode_text(1), &[]);
5079 let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
5080 assert!(IrModule::parse(&data).is_ok());
5081 }
5082
5083 #[test]
5086 fn walk_stray_fallback_in_a_nested_range_is_rejected_at_load() {
5087 let then_ops = vec![0x0E]; let mut main_ops = encode_show_if(0, &then_ops, &[]);
5096 main_ops.extend_from_slice(&encode_text(1));
5097 let opcodes = encode_try(&main_ops, &encode_text(2));
5098
5099 let data = build_minimal_ir(
5100 &["flag", "main", "fb"],
5101 &[(0, 0, 0x02, 0x00, &[])],
5102 &opcodes,
5103 &[],
5104 );
5105 assert!(matches!(
5106 IrModule::parse(&data).unwrap_err(),
5107 IrError::MalformedControlFlow { .. }
5108 ));
5109 }
5110
5111 #[test]
5112 fn walk_try_without_a_fallback_is_rejected_at_load() {
5113 let mut opcodes = vec![0x0D]; opcodes.extend_from_slice(&4u32.to_le_bytes()); opcodes.extend_from_slice(&encode_text(0));
5118
5119 let data = build_minimal_ir(&["main"], &[], &opcodes, &[]);
5120 assert!(matches!(
5121 IrModule::parse(&data).unwrap_err(),
5122 IrError::MalformedControlFlow { .. }
5123 ));
5124 }
5125
5126 #[test]
5129 fn walk_island_attrs_survive_an_empty_show_if_before_the_root() {
5130 let mut opcodes = encode_island_start(0);
5135 opcodes.extend_from_slice(&encode_show_if(0, &[], &[]));
5136 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
5137 opcodes.extend_from_slice(&encode_close_tag(1));
5138 opcodes.extend_from_slice(&encode_island_end(0));
5139
5140 let slots = SlotData::new(1);
5141 let html = walk_with_islands(
5142 &["Comp", "div", "flag"],
5143 &[(0, 2, 0x02, 0x00, &[])],
5144 &[(0, 0x01, 0x01, 0, 0, &[])],
5145 &opcodes,
5146 &slots,
5147 );
5148 assert!(
5149 html.contains("<div data-forma-island=\"0\""),
5150 "island root must keep its attributes, got: {html}"
5151 );
5152 }
5153
5154 #[test]
5155 fn walk_list_key_survives_an_empty_show_if_before_the_item_element() {
5156 let mut body = encode_list_item_key(3);
5157 body.extend_from_slice(&encode_show_if(1, &[], &[]));
5158 body.extend_from_slice(&encode_open_tag(2, &[]));
5159 body.extend_from_slice(&encode_close_tag(2));
5160 let opcodes = encode_list(0, 1, &body);
5161
5162 let mut slots = SlotData::new(2);
5163 slots.set(
5164 0,
5165 SlotValue::array(vec![
5166 SlotValue::Text("a".into()),
5167 SlotValue::Text("b".into()),
5168 ]),
5169 );
5170
5171 let html = walk_with_slots(
5172 &["items", "item", "li", "k"],
5173 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
5174 &opcodes,
5175 &slots,
5176 );
5177 assert_eq!(
5178 html.matches("data-forma-key=\"k\"").count(),
5179 2,
5180 "every item element must keep its key, got: {html}"
5181 );
5182 }
5183
5184 #[test]
5185 fn walk_island_attrs_do_not_leak_past_the_island_end() {
5186 let mut opcodes = encode_island_start(0);
5190 opcodes.extend_from_slice(&encode_island_end(0));
5191 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
5192 opcodes.extend_from_slice(&encode_close_tag(1));
5193
5194 let slots = SlotData::new(0);
5195 let html = walk_with_islands(
5196 &["Comp", "div"],
5197 &[],
5198 &[(0, 0x01, 0x01, 0, 0, &[])],
5199 &opcodes,
5200 &slots,
5201 );
5202 assert!(
5203 !html.contains("data-forma-island"),
5204 "island attributes must not leak onto an element outside the island, got: {html}"
5205 );
5206 }
5207
5208 #[test]
5209 fn walk_list_key_does_not_leak_past_the_list() {
5210 let body = encode_list_item_key(2); let mut opcodes = encode_list(0, 1, &body);
5212 opcodes.extend_from_slice(&encode_open_tag(3, &[]));
5213 opcodes.extend_from_slice(&encode_close_tag(3));
5214
5215 let mut slots = SlotData::new(2);
5216 slots.set(0, SlotValue::array(vec![SlotValue::Text("a".into())]));
5217
5218 let html = walk_with_slots(
5219 &["items", "item", "k", "div"],
5220 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
5221 &opcodes,
5222 &slots,
5223 );
5224 assert!(
5225 !html.contains("data-forma-key"),
5226 "list key must not leak onto an element after the list, got: {html}"
5227 );
5228 }
5229
5230 #[test]
5233 fn walk_rejects_hostile_tag_name() {
5234 let opcodes = encode_open_tag(0, &[]);
5237 let data = build_minimal_ir(&["div onload=\"alert(1)\""], &[], &opcodes, &[]);
5238 let module = IrModule::parse(&data).unwrap();
5239 let slots = SlotData::new(0);
5240 assert!(matches!(
5241 walk_to_html(&module, &slots),
5242 Err(IrError::InvalidName(_))
5243 ));
5244 }
5245
5246 #[test]
5247 fn walk_rejects_hostile_close_tag_name() {
5248 let mut opcodes = encode_open_tag(0, &[]);
5249 opcodes.extend_from_slice(&encode_close_tag(1));
5250 let data = build_minimal_ir(&["div", "div><script"], &[], &opcodes, &[]);
5251 let module = IrModule::parse(&data).unwrap();
5252 let slots = SlotData::new(0);
5253 assert!(matches!(
5254 walk_to_html(&module, &slots),
5255 Err(IrError::InvalidName(_))
5256 ));
5257 }
5258
5259 #[test]
5260 fn walk_drops_hostile_attribute_names() {
5261 let opcodes = encode_open_tag(0, &[(1, 2), (3, 2)]);
5264 let data = build_minimal_ir(&["div", "x\"><script", "v", "ok-attr"], &[], &opcodes, &[]);
5265 let module = IrModule::parse(&data).unwrap();
5266 let slots = SlotData::new(0);
5267 let html = walk_to_html(&module, &slots).unwrap();
5268 assert_eq!(html, "<div ok-attr=\"v\">");
5269 }
5270
5271 #[test]
5272 fn walk_drops_hostile_boolean_attribute_name() {
5273 let opcodes = encode_open_tag(0, &[(1, 2)]);
5275 let data = build_minimal_ir(&["div", "y\"><script", ""], &[], &opcodes, &[]);
5276 let module = IrModule::parse(&data).unwrap();
5277 let slots = SlotData::new(0);
5278 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
5279 }
5280
5281 #[test]
5282 fn walk_drops_hostile_dyn_attr_name() {
5283 let mut opcodes = encode_open_tag(0, &[]);
5284 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5285 let data = build_minimal_ir(
5286 &["div", "z\"><script", "val"],
5287 &[(0, 2, 0x01, 0x00, &[])],
5288 &opcodes,
5289 &[],
5290 );
5291 let module = IrModule::parse(&data).unwrap();
5292 let mut slots = SlotData::new(1);
5293 slots.set(0, SlotValue::Text("x".into()));
5294 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
5295 }
5296
5297 #[test]
5300 fn walk_drops_javascript_url_in_dyn_attr() {
5301 let mut opcodes = encode_open_tag(0, &[]);
5302 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5303 let data = build_minimal_ir(
5304 &["a", "href", "url"],
5305 &[(0, 2, 0x01, 0x00, &[])],
5306 &opcodes,
5307 &[],
5308 );
5309 let module = IrModule::parse(&data).unwrap();
5310
5311 for payload in [
5314 "javascript:alert(1)",
5315 "JaVaScRiPt:alert(1)",
5316 "java\tscript:alert(1)",
5317 " javascript:alert(1)",
5318 "vbscript:msgbox(1)",
5319 "data:text/html,<script>alert(1)</script>",
5320 ] {
5321 let mut slots = SlotData::new(1);
5322 slots.set(0, SlotValue::Text(payload.to_string()));
5323 let html = walk_to_html(&module, &slots).unwrap();
5324 assert_eq!(html, "<a>", "payload {payload:?} was not dropped");
5327 }
5328 }
5329
5330 #[test]
5331 fn walk_keeps_safe_urls_and_data_images() {
5332 let mut opcodes = encode_open_tag(0, &[]);
5333 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5334 let data = build_minimal_ir(
5335 &["img", "src", "url"],
5336 &[(0, 2, 0x01, 0x00, &[])],
5337 &opcodes,
5338 &[],
5339 );
5340 let module = IrModule::parse(&data).unwrap();
5341
5342 for payload in [
5343 "/about",
5344 "https://example.com/x?a=1",
5345 "data:image/png;base64,AAAA",
5346 "data:image/svg+xml,%3Csvg%3E%3C/svg%3E",
5350 ] {
5351 let mut slots = SlotData::new(1);
5352 slots.set(0, SlotValue::Text(payload.to_string()));
5353 let html = walk_to_html(&module, &slots).unwrap();
5354 assert!(html.contains("src="), "payload {payload:?} was dropped");
5355 }
5356 }
5357
5358 #[test]
5359 fn walk_blocks_svg_data_url_outside_image_context() {
5360 let mut opcodes = encode_open_tag(0, &[]);
5361 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5362 let data = build_minimal_ir(
5363 &["a", "href", "url"],
5364 &[(0, 2, 0x01, 0x00, &[])],
5365 &opcodes,
5366 &[],
5367 );
5368 let module = IrModule::parse(&data).unwrap();
5369 let mut slots = SlotData::new(1);
5370 slots.set(
5371 0,
5372 SlotValue::Text("data:image/svg+xml,%3Csvg onload=alert(1)%3E".into()),
5373 );
5374 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<a>");
5377 }
5378
5379 #[test]
5380 fn walk_drops_event_handler_attributes() {
5381 let opcodes = encode_open_tag(0, &[(1, 2)]);
5383 let data = build_minimal_ir(&["div", "onmouseover", "alert(1)"], &[], &opcodes, &[]);
5384 let module = IrModule::parse(&data).unwrap();
5385 assert_eq!(
5386 walk_to_html(&module, &SlotData::new(0)).unwrap(),
5387 "<div>",
5388 "static on* attribute must be dropped"
5389 );
5390
5391 let mut opcodes = encode_open_tag(0, &[]);
5393 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5394 let data = build_minimal_ir(
5395 &["div", "ONCLICK", "h"],
5396 &[(0, 2, 0x01, 0x00, &[])],
5397 &opcodes,
5398 &[],
5399 );
5400 let module = IrModule::parse(&data).unwrap();
5401 let mut slots = SlotData::new(1);
5402 slots.set(0, SlotValue::Text("alert(1)".into()));
5403 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
5404 }
5405
5406 #[test]
5407 fn walk_drops_javascript_url_in_static_attr() {
5408 let opcodes = encode_open_tag(0, &[(1, 2)]);
5412 let data = build_minimal_ir(&["a", "href", "javascript:alert(1)"], &[], &opcodes, &[]);
5413 let module = IrModule::parse(&data).unwrap();
5414 assert_eq!(walk_to_html(&module, &SlotData::new(0)).unwrap(), "<a>");
5415 }
5416
5417 #[test]
5418 fn walk_comment_cannot_self_close() {
5419 for text in ["><img src=x onerror=alert(1)>", "-><img src=x>"] {
5422 let opcodes = encode_comment(0);
5423 let data = build_minimal_ir(&[text], &[], &opcodes, &[]);
5424 let module = IrModule::parse(&data).unwrap();
5425 let html = walk_to_html(&module, &SlotData::new(0)).unwrap();
5426 assert!(
5427 html.starts_with("<!-- "),
5428 "comment must not start with a closing sequence, got: {html}"
5429 );
5430 }
5431 }
5432
5433 #[test]
5436 fn walk_switch_matches_on_js_number_text() {
5437 let opcodes = encode_switch(0, &[(1, &encode_text(2)), (3, &encode_text(4))]);
5440 let data = build_minimal_ir(
5441 &["n", "1e+21", "big", "1000", "small"],
5442 &[(0, 0, 0x03, 0x00, &[])],
5443 &opcodes,
5444 &[],
5445 );
5446 let module = IrModule::parse(&data).unwrap();
5447
5448 let mut slots = SlotData::new(1);
5449 slots.set(0, SlotValue::Number(1e21));
5450 assert!(walk_to_html(&module, &slots).unwrap().contains("big"));
5451
5452 let mut slots = SlotData::new(1);
5453 slots.set(0, SlotValue::Number(1000.0));
5454 assert!(walk_to_html(&module, &slots).unwrap().contains("small"));
5455 }
5456
5457 struct Page {
5470 strings: Vec<String>,
5471 ops: Vec<u8>,
5472 slots: Vec<(u8, SlotValue)>,
5473 islands: Vec<(u16, u32, u32)>,
5474 }
5475
5476 impl Page {
5477 fn new() -> Self {
5478 Page {
5479 strings: Vec::new(),
5480 ops: Vec::new(),
5481 slots: Vec::new(),
5482 islands: Vec::new(),
5483 }
5484 }
5485
5486 fn intern(&mut self, s: &str) -> u32 {
5487 let index = match self.strings.iter().position(|x| x == s) {
5488 Some(i) => i,
5489 None => {
5490 self.strings.push(s.to_string());
5491 self.strings.len() - 1
5492 }
5493 };
5494 u32::try_from(index).unwrap()
5495 }
5496
5497 fn slot(&mut self, value: SlotValue) -> u16 {
5499 let type_byte = match &value {
5500 SlotValue::Null | SlotValue::Text(_) => 0x01,
5501 SlotValue::Bool(_) => 0x02,
5502 SlotValue::Number(_) => 0x03,
5503 SlotValue::Array(_) => 0x04,
5504 SlotValue::Object(_) => 0x05,
5505 };
5506 self.slots.push((type_byte, value));
5507 u16::try_from(self.slots.len() - 1).unwrap()
5508 }
5509
5510 fn open(&mut self, tag: &str, attrs: &[(&str, &str)]) -> &mut Self {
5511 let tag = self.intern(tag);
5512 let attrs: Vec<(u32, u32)> = attrs
5513 .iter()
5514 .map(|(k, v)| (self.intern(k), self.intern(v)))
5515 .collect();
5516 self.ops.extend_from_slice(&encode_open_tag(tag, &attrs));
5517 self
5518 }
5519
5520 fn close(&mut self, tag: &str) -> &mut Self {
5521 let tag = self.intern(tag);
5522 self.ops.extend_from_slice(&encode_close_tag(tag));
5523 self
5524 }
5525
5526 fn text(&mut self, text: &str) -> &mut Self {
5527 let text = self.intern(text);
5528 self.ops.extend_from_slice(&encode_text(text));
5529 self
5530 }
5531
5532 fn dyn_text(&mut self, slot: u16) -> &mut Self {
5534 self.ops.extend_from_slice(&encode_dyn_text(slot, slot));
5535 self
5536 }
5537
5538 fn dyn_attr(&mut self, name: &str, slot: u16) -> &mut Self {
5539 let name = self.intern(name);
5540 self.ops.extend_from_slice(&encode_dyn_attr(name, slot));
5541 self
5542 }
5543
5544 fn prop(&mut self, source: u16, name: &str, target: u16) -> &mut Self {
5546 let name = self.intern(name);
5547 self.ops
5548 .extend_from_slice(&encode_prop(source, name, target));
5549 self
5550 }
5551
5552 fn body(&mut self, f: impl FnOnce(&mut Self)) -> Vec<u8> {
5554 let outer = std::mem::take(&mut self.ops);
5555 f(self);
5556 std::mem::replace(&mut self.ops, outer)
5557 }
5558
5559 fn show_if(
5560 &mut self,
5561 slot: u16,
5562 then_ops: impl FnOnce(&mut Self),
5563 else_ops: impl FnOnce(&mut Self),
5564 ) -> &mut Self {
5565 let then_ops = self.body(then_ops);
5566 let else_ops = self.body(else_ops);
5567 self.ops
5568 .extend_from_slice(&encode_show_if(slot, &then_ops, &else_ops));
5569 self
5570 }
5571
5572 fn list(&mut self, slot: u16, item: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
5573 let body = self.body(body);
5574 self.ops.extend_from_slice(&encode_list(slot, item, &body));
5575 self
5576 }
5577
5578 fn island(&mut self, id: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
5581 let name = self.intern(&format!("Island{id}"));
5582 let offset = u32::try_from(self.ops.len()).unwrap();
5583 self.islands.push((id, name, offset));
5584 self.ops.extend_from_slice(&encode_island_start(id));
5585 body(self);
5586 self.ops.extend_from_slice(&encode_island_end(id));
5587 self
5588 }
5589
5590 fn module_and_slots(&self) -> (IrModule, SlotData) {
5591 let mut strings = self.strings.clone();
5592 let mut decls = Vec::new();
5593 for (id, (type_byte, _)) in self.slots.iter().enumerate() {
5594 strings.push(format!("slot{id}"));
5595 let name = u32::try_from(strings.len() - 1).unwrap();
5596 decls.push((u16::try_from(id).unwrap(), name, *type_byte, 0x00, &[][..]));
5597 }
5598 let islands: Vec<(u16, u8, u8, u32, u32, &[u16])> = self
5599 .islands
5600 .iter()
5601 .map(|&(id, name, offset)| (id, 0x01, 0x01, name, offset, &[][..]))
5602 .collect();
5603 let strings: Vec<&str> = strings.iter().map(String::as_str).collect();
5604 let data = build_minimal_ir(&strings, &decls, &self.ops, &islands);
5605 let module = IrModule::parse(&data).unwrap();
5606 let mut slots = SlotData::new(self.slots.len());
5607 for (id, (_, value)) in self.slots.iter().enumerate() {
5608 slots.set(u16::try_from(id).unwrap(), value.clone());
5609 }
5610 (module, slots)
5611 }
5612
5613 fn render(&self) -> String {
5614 let (module, slots) = self.module_and_slots();
5615 walk_to_html(&module, &slots).unwrap()
5616 }
5617
5618 fn render_island(&self, id: u16) -> String {
5619 let (module, slots) = self.module_and_slots();
5620 walk_island(&module, &slots, id).unwrap()
5621 }
5622 }
5623
5624 fn text(s: &str) -> SlotValue {
5625 SlotValue::Text(s.to_string())
5626 }
5627
5628 #[test]
5629 fn dyn_text_in_a_text_only_parent_is_written_without_markers() {
5630 for parent in ["textarea", "title", "style", "script", "TEXTAREA"] {
5635 let mut page = Page::new();
5636 let inside = page.slot(text("a<b&c"));
5637 let outside = page.slot(text("world"));
5638 page.open("div", &[])
5639 .open(parent, &[])
5640 .dyn_text(inside)
5641 .close(parent)
5642 .open("p", &[])
5643 .dyn_text(outside)
5644 .close("p")
5645 .close("div");
5646 assert_eq!(
5647 page.render(),
5648 format!(
5649 "<div><{parent}>a<b&c</{parent}>\
5650 <p><!--f:t1-->world<!--/f:t1--></p></div>"
5651 ),
5652 "inside <{parent}>"
5653 );
5654 }
5655 }
5656
5657 #[test]
5658 fn dyn_text_outside_the_text_only_parents_keeps_its_markers() {
5659 for parent in ["pre", "option", "span", "titles", "xtextarea", "noscript"] {
5662 let mut page = Page::new();
5663 let value = page.slot(text("hello"));
5664 page.open(parent, &[]).dyn_text(value).close(parent);
5665 assert_eq!(
5666 page.render(),
5667 format!("<{parent}><!--f:t0-->hello<!--/f:t0--></{parent}>"),
5668 "inside <{parent}>"
5669 );
5670 }
5671 }
5672
5673 #[test]
5674 fn an_empty_value_in_a_text_only_parent_writes_nothing() {
5675 let mut page = Page::new();
5678 let inside = page.slot(text(""));
5679 let outside = page.slot(text(""));
5680 page.open("textarea", &[])
5681 .dyn_text(inside)
5682 .close("textarea")
5683 .open("p", &[])
5684 .dyn_text(outside)
5685 .close("p");
5686 assert_eq!(
5687 page.render(),
5688 "<textarea></textarea><p><!--f:t1-->\u{200B}<!--/f:t1--></p>"
5689 );
5690 }
5691
5692 #[test]
5693 fn a_textarea_doubles_a_leading_line_feed() {
5694 fn textarea(build: impl FnOnce(&mut Page)) -> String {
5698 let mut page = Page::new();
5699 page.open("textarea", &[]);
5700 build(&mut page);
5701 page.close("textarea");
5702 page.render()
5703 }
5704 let cases: [(&str, String, &str); 5] = [
5705 (
5706 "a dynamic value",
5707 textarea(|p| {
5708 let v = p.slot(text("\nhello"));
5709 p.dyn_text(v);
5710 }),
5711 "<textarea>\n\nhello</textarea>",
5712 ),
5713 (
5714 "a CRLF value",
5715 textarea(|p| {
5716 let v = p.slot(text("\r\nhello"));
5717 p.dyn_text(v);
5718 }),
5719 "<textarea>\n\r\nhello</textarea>",
5720 ),
5721 (
5722 "an empty value first: the first written byte decides",
5723 textarea(|p| {
5724 let empty = p.slot(text(""));
5725 let v = p.slot(text("\nx"));
5726 p.dyn_text(empty).dyn_text(v);
5727 }),
5728 "<textarea>\n\nx</textarea>",
5729 ),
5730 (
5731 "an LF after other content is kept as is",
5732 textarea(|p| {
5733 let v = p.slot(text("\nb"));
5734 p.text("a").dyn_text(v);
5735 }),
5736 "<textarea>a\nb</textarea>",
5737 ),
5738 (
5739 "static text",
5740 textarea(|p| {
5741 p.text("\nstatic");
5742 }),
5743 "<textarea>\n\nstatic</textarea>",
5744 ),
5745 ];
5746 for (case, html, expected) in cases {
5747 assert_eq!(html, expected, "{case}");
5748 }
5749
5750 let mut page = Page::new();
5752 let v = page.slot(text("\nhello"));
5753 page.open("title", &[]).dyn_text(v).close("title");
5754 assert_eq!(page.render(), "<title>\nhello</title>");
5755 }
5756
5757 #[test]
5758 fn no_marker_kind_is_written_inside_a_text_only_parent() {
5759 let mut page = Page::new();
5762 let loading = page.slot(SlotValue::Bool(false));
5763 let name = page.slot(text("Ada"));
5764 let tags = page.slot(SlotValue::array(vec![text("x"), text("y")]));
5765 let tag = page.slot(SlotValue::Null);
5766 page.open("title", &[])
5767 .show_if(
5768 loading,
5769 |p| {
5770 p.text("Loading");
5771 },
5772 |p| {
5773 p.dyn_text(name);
5774 },
5775 )
5776 .text(" | ")
5777 .list(tags, tag, |p| {
5778 p.dyn_text(tag).text(";");
5779 })
5780 .close("title")
5781 .open("p", &[])
5782 .show_if(
5783 loading,
5784 |p| {
5785 p.text("Loading");
5786 },
5787 |p| {
5788 p.dyn_text(name);
5789 },
5790 )
5791 .close("p");
5792 assert_eq!(
5793 page.render(),
5794 "<title>Ada | x;y;</title>\
5795 <p><!--f:s0--><!--f:t1-->Ada<!--/f:t1--><!--/f:s0--></p>"
5796 );
5797
5798 let mut page = Page::new();
5800 page.open("textarea", &[])
5801 .island(0, |p| {
5802 p.text("x");
5803 })
5804 .close("textarea")
5805 .island(1, |p| {
5806 p.open("div", &[]).close("div");
5807 });
5808 assert_eq!(
5809 page.render(),
5810 "<textarea>x</textarea><!--f:i1--><div data-forma-island=\"1\" \
5811 data-forma-component=\"Island1\" data-forma-status=\"pending\" \
5812 data-forma-hydrate=\"load\"></div><!--/f:i1-->"
5813 );
5814 }
5815
5816 #[test]
5817 fn a_textarea_value_binding_is_written_as_its_text() {
5818 let cases = [
5822 (text("hello"), "hello"),
5823 (
5824 text("</textarea><script>x</script>"),
5825 "</textarea><script>x</script>",
5826 ),
5827 (text("\nhello"), "\n\nhello"),
5828 (SlotValue::Null, ""),
5829 (SlotValue::Number(0.0), "0"),
5830 (SlotValue::Bool(false), "false"),
5831 ];
5832 for (value, inner) in cases {
5833 let mut page = Page::new();
5834 let v = page.slot(value.clone());
5835 let note = page.slot(text("note"));
5836 page.open("textarea", &[("name", "t")])
5837 .dyn_attr("value", v)
5838 .close("textarea")
5839 .open("p", &[])
5840 .dyn_text(note)
5841 .close("p");
5842 assert_eq!(
5843 page.render(),
5844 format!(
5845 "<textarea name=\"t\">{inner}</textarea><p><!--f:t1-->note<!--/f:t1--></p>"
5846 ),
5847 "value {value:?}"
5848 );
5849 }
5850
5851 let mut page = Page::new();
5853 page.open("textarea", &[("name", "t"), ("value", "hello")])
5854 .close("textarea");
5855 assert_eq!(page.render(), "<textarea name=\"t\">hello</textarea>");
5856 }
5857
5858 #[test]
5859 fn a_textarea_value_binding_wins_over_its_children() {
5860 let mut page = Page::new();
5863 let v = page.slot(text("bound"));
5864 let child = page.slot(text("child"));
5865 page.open("textarea", &[])
5866 .dyn_attr("value", v)
5867 .dyn_text(child)
5868 .text(" default")
5869 .close("textarea")
5870 .open("p", &[])
5871 .dyn_text(child)
5872 .close("p");
5873 assert_eq!(
5874 page.render(),
5875 "<textarea>bound</textarea><p><!--f:t1-->child<!--/f:t1--></p>"
5876 );
5877 }
5878
5879 fn select_page(value: SlotValue) -> String {
5882 let mut page = Page::new();
5883 let v = page.slot(value);
5884 page.open("select", &[("name", "c")]).dyn_attr("value", v);
5885 for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
5886 page.open("option", &[("value", value)])
5887 .text(label)
5888 .close("option");
5889 }
5890 page.close("select");
5891 page.render()
5892 }
5893
5894 fn option(value: &str, label: &str, selected: bool) -> String {
5895 let selected = if selected { " selected" } else { "" };
5896 format!("<option value=\"{value}\"{selected}>{label}</option>")
5897 }
5898
5899 #[test]
5900 fn a_select_value_binding_selects_the_matching_option() {
5901 for (value, chosen) in [("a", "a"), ("b", "b"), ("c", "c"), ("z", "")] {
5904 let expected = format!(
5905 "<select name=\"c\">{}{}{}</select>",
5906 option("a", "Apple", chosen == "a"),
5907 option("b", "Banana", chosen == "b"),
5908 option("c", "Cherry", chosen == "c"),
5909 );
5910 assert_eq!(select_page(text(value)), expected, "value {value}");
5911 }
5912 }
5913
5914 #[test]
5915 fn a_select_value_binding_compares_as_the_client_string() {
5916 for (value, chosen) in [(SlotValue::Number(2.0), "2"), (SlotValue::Null, "")] {
5918 let mut page = Page::new();
5919 let v = page.slot(value.clone());
5920 page.open("select", &[]).dyn_attr("value", v);
5921 for (value, label) in [("", "Choose"), ("1", "One"), ("2", "Two")] {
5922 page.open("option", &[("value", value)])
5923 .text(label)
5924 .close("option");
5925 }
5926 page.close("select");
5927 let placeholder = if chosen.is_empty() {
5929 "<option value selected>Choose</option>"
5930 } else {
5931 "<option value>Choose</option>"
5932 };
5933 let expected = format!(
5934 "<select>{placeholder}{}{}</select>",
5935 option("1", "One", chosen == "1"),
5936 option("2", "Two", chosen == "2"),
5937 );
5938 assert_eq!(page.render(), expected, "value {value:?}");
5939 }
5940 }
5941
5942 #[test]
5943 fn a_select_value_binding_matches_an_option_without_a_value_by_its_text() {
5944 let render = |value: &str| {
5947 let mut page = Page::new();
5948 let v = page.slot(text(value));
5949 let label = page.slot(text("Blue"));
5950 page.open("select", &[])
5951 .dyn_attr("value", v)
5952 .open("option", &[])
5953 .text("Red")
5954 .close("option")
5955 .open("option", &[])
5956 .text(" Dark \n green ")
5957 .close("option")
5958 .open("option", &[])
5959 .dyn_text(label)
5960 .close("option")
5961 .close("select");
5962 page.render()
5963 };
5964 assert_eq!(
5965 render("Dark green"),
5966 "<select><option>Red</option><option selected> Dark \n green </option>\
5967 <option><!--f:t1-->Blue<!--/f:t1--></option></select>"
5968 );
5969 assert_eq!(
5970 render("Blue"),
5971 "<select><option>Red</option><option> Dark \n green </option>\
5972 <option selected><!--f:t1-->Blue<!--/f:t1--></option></select>"
5973 );
5974 }
5975
5976 #[test]
5977 fn a_select_value_binding_reaches_options_rendered_by_a_list() {
5978 let short: Vec<(String, String)> = [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")]
5983 .iter()
5984 .map(|(id, label)| (id.to_string(), label.to_string()))
5985 .collect();
5986 let long: Vec<(String, String)> = (0..45)
5987 .map(|i| (format!("r{i}"), format!("Row {i}")))
5988 .collect();
5989 let cases: [(&[(String, String)], &str, usize); 4] = [
5990 (&short, "a", 0),
5991 (&short, "c", 2),
5992 (&long, "r40", 40),
5993 (&long, "r44", 44),
5994 ];
5995 for (rows_in, value, chosen) in cases {
5996 let mut page = Page::new();
5997 let v = page.slot(text(value));
5998 let rows = rows_in
5999 .iter()
6000 .map(|(id, label)| {
6001 SlotValue::object(vec![
6002 ("id".to_string(), text(id)),
6003 ("label".to_string(), text(label)),
6004 ])
6005 })
6006 .collect();
6007 let items = page.slot(SlotValue::array(rows));
6008 let item = page.slot(SlotValue::Null);
6009 let id = page.slot(SlotValue::Null);
6010 let label = page.slot(SlotValue::Null);
6011 page.open("select", &[])
6012 .dyn_attr("value", v)
6013 .list(items, item, |p| {
6014 p.prop(item, "id", id)
6015 .prop(item, "label", label)
6016 .open("option", &[])
6017 .dyn_attr("value", id)
6018 .dyn_text(label)
6019 .close("option");
6020 })
6021 .close("select");
6022 let expected: String = rows_in
6023 .iter()
6024 .enumerate()
6025 .map(|(i, (id, label))| {
6026 let selected = if i == chosen { " selected" } else { "" };
6027 format!(
6028 "<option value=\"{id}\"{selected}><!--f:t4-->{label}<!--/f:t4--></option>"
6029 )
6030 })
6031 .collect();
6032 assert_eq!(
6033 page.render(),
6034 format!("<select><!--f:l1-->{expected}<!--/f:l1--></select>"),
6035 "value {value}"
6036 );
6037 }
6038 }
6039
6040 #[test]
6041 fn a_select_binding_ends_with_its_select() {
6042 let mut page = Page::new();
6047 let v = page.slot(text("b"));
6048 page.open("select", &[])
6049 .dyn_attr("value", v)
6050 .open("option", &[("value", "a")])
6051 .text("A")
6052 .close("option")
6053 .close("select")
6054 .open("datalist", &[])
6055 .open("option", &[("value", "b")])
6056 .text("B")
6057 .close("option")
6058 .close("datalist");
6059 assert_eq!(
6060 page.render(),
6061 format!(
6062 "<select>{}</select><datalist>{}</datalist>",
6063 option("a", "A", false),
6064 option("b", "B", false)
6065 )
6066 );
6067 }
6068
6069 #[test]
6070 fn an_options_first_value_attribute_is_its_value() {
6071 for (bound, selected) in [("a", " selected"), ("b", "")] {
6074 let mut page = Page::new();
6075 let v = page.slot(text(bound));
6076 let second = page.slot(text("b"));
6077 page.open("select", &[])
6078 .dyn_attr("value", v)
6079 .open("option", &[("value", "a")])
6080 .dyn_attr("value", second)
6081 .text("Only")
6082 .close("option")
6083 .close("select");
6084 assert_eq!(
6085 page.render(),
6086 format!("<select><option value=\"a\" value=\"b\"{selected}>Only</option></select>"),
6087 "bound to {bound}"
6088 );
6089 }
6090 }
6091
6092 #[test]
6093 fn multiple_counts_only_when_the_attribute_is_written() {
6094 for (multiple, attr, chosen) in [(false, "", false), (true, " multiple", true)] {
6099 let mut page = Page::new();
6100 let flag = page.slot(SlotValue::Bool(multiple));
6101 let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
6102 page.open("select", &[])
6103 .dyn_attr("multiple", flag)
6104 .dyn_attr("value", v);
6105 for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
6106 page.open("option", &[("value", value)])
6107 .text(label)
6108 .close("option");
6109 }
6110 page.close("select");
6111 assert_eq!(
6112 page.render(),
6113 format!(
6114 "<select{attr}>{}{}{}</select>",
6115 option("a", "Apple", chosen),
6116 option("b", "Banana", false),
6117 option("c", "Cherry", chosen)
6118 ),
6119 "multiple {multiple}"
6120 );
6121 }
6122 }
6123
6124 #[test]
6125 fn a_null_member_of_a_multiple_value_compares_as_null() {
6126 let mut page = Page::new();
6129 let v = page.slot(SlotValue::array(vec![SlotValue::Null, text("b")]));
6130 page.open("select", &[("multiple", "")])
6131 .dyn_attr("value", v);
6132 for (value, label) in [("null", "Null"), ("x", "Empty"), ("b", "Banana")] {
6133 page.open("option", &[("value", value)])
6134 .text(label)
6135 .close("option");
6136 }
6137 page.close("select");
6138 assert_eq!(
6142 page.render(),
6143 format!(
6144 "<select multiple>{}{}{}</select>",
6145 option("null", "Null", true),
6146 option("x", "Empty", false),
6147 option("b", "Banana", true)
6148 )
6149 );
6150 }
6151
6152 #[test]
6153 fn an_option_ends_at_the_next_option_or_optgroup() {
6154 let mut page = Page::new();
6158 let v = page.slot(text("A"));
6159 page.open("select", &[])
6160 .dyn_attr("value", v)
6161 .open("option", &[])
6162 .text("A")
6163 .open("option", &[])
6164 .text("B")
6165 .close("select");
6166 assert_eq!(
6167 page.render(),
6168 "<select><option selected>A<option>B</select>",
6169 "ended by <option>"
6170 );
6171
6172 let mut page = Page::new();
6173 let v = page.slot(text("A"));
6174 page.open("select", &[])
6175 .dyn_attr("value", v)
6176 .open("option", &[])
6177 .text("A")
6178 .open("optgroup", &[("label", "g")])
6179 .open("option", &[("value", "x")])
6180 .text("X")
6181 .close("option")
6182 .close("optgroup")
6183 .close("select");
6184 assert_eq!(
6185 page.render(),
6186 "<select><option selected>A<optgroup label=\"g\">\
6187 <option value=\"x\">X</option></optgroup></select>",
6188 "ended by <optgroup>"
6189 );
6190 }
6191
6192 #[test]
6193 fn a_single_select_selects_only_the_first_matching_option() {
6194 let mut page = Page::new();
6197 let v = page.slot(text("b"));
6198 page.open("select", &[])
6199 .dyn_attr("value", v)
6200 .open("option", &[("value", "b")])
6201 .text("First")
6202 .close("option")
6203 .open("option", &[("value", "b")])
6204 .text("Second")
6205 .close("option")
6206 .close("select");
6207 assert_eq!(
6208 page.render(),
6209 format!(
6210 "<select>{}{}</select>",
6211 option("b", "First", true),
6212 option("b", "Second", false)
6213 )
6214 );
6215 }
6216
6217 #[test]
6218 fn a_multiple_select_selects_every_option_in_an_array_value() {
6219 let render = |attrs: &[(&str, &str)]| {
6223 let mut page = Page::new();
6224 let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
6225 page.open("select", attrs).dyn_attr("value", v);
6226 for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
6227 page.open("option", &[("value", value)])
6228 .text(label)
6229 .close("option");
6230 }
6231 page.close("select");
6232 page.render()
6233 };
6234 assert_eq!(
6235 render(&[("multiple", "")]),
6236 format!(
6237 "<select multiple>{}{}{}</select>",
6238 option("a", "Apple", true),
6239 option("b", "Banana", false),
6240 option("c", "Cherry", true)
6241 )
6242 );
6243 assert_eq!(
6244 render(&[]),
6245 format!(
6246 "<select>{}{}{}</select>",
6247 option("a", "Apple", false),
6248 option("b", "Banana", false),
6249 option("c", "Cherry", false)
6250 )
6251 );
6252 }
6253
6254 #[test]
6255 fn a_bound_select_overrides_authored_selected_on_its_options() {
6256 let render = |bound: bool| {
6259 let mut page = Page::new();
6260 let v = page.slot(text("a"));
6261 let on = page.slot(SlotValue::Bool(true));
6262 page.open("select", &[]);
6263 if bound {
6264 page.dyn_attr("value", v);
6265 }
6266 page.open("option", &[("value", "a")])
6267 .text("A")
6268 .close("option")
6269 .open("option", &[("value", "b"), ("selected", "")])
6270 .text("B")
6271 .close("option")
6272 .open("option", &[("value", "c")])
6273 .dyn_attr("selected", on)
6274 .text("C")
6275 .close("option")
6276 .close("select");
6277 page.render()
6278 };
6279 assert_eq!(
6280 render(true),
6281 format!(
6282 "<select>{}{}{}</select>",
6283 option("a", "A", true),
6284 option("b", "B", false),
6285 option("c", "C", false)
6286 )
6287 );
6288 assert_eq!(
6289 render(false),
6290 format!(
6291 "<select>{}{}{}</select>",
6292 option("a", "A", false),
6293 option("b", "B", true),
6294 option("c", "C", true)
6295 )
6296 );
6297 }
6298
6299 #[test]
6300 fn a_static_select_value_attribute_selects_its_option() {
6301 let mut page = Page::new();
6302 page.open("select", &[("value", "b")])
6303 .open("option", &[("value", "a")])
6304 .text("A")
6305 .close("option")
6306 .open("option", &[("value", "b")])
6307 .text("B")
6308 .close("option")
6309 .close("select");
6310 assert_eq!(
6311 page.render(),
6312 format!(
6313 "<select>{}{}</select>",
6314 option("a", "A", false),
6315 option("b", "B", true)
6316 )
6317 );
6318 }
6319
6320 #[test]
6321 fn the_context_rules_hold_in_an_island_fragment() {
6322 let mut page = Page::new();
6325 let body = page.slot(text("hello"));
6326 let choice = page.slot(text("b"));
6327 page.island(0, |p| {
6328 p.open("form", &[])
6329 .open("textarea", &[])
6330 .dyn_text(body)
6331 .close("textarea")
6332 .open("select", &[])
6333 .dyn_attr("value", choice)
6334 .open("option", &[("value", "a")])
6335 .text("A")
6336 .close("option")
6337 .open("option", &[("value", "b")])
6338 .text("B")
6339 .close("option")
6340 .close("select")
6341 .close("form");
6342 });
6343 let inner = format!(
6344 "<textarea>hello</textarea><select>{}{}</select>",
6345 option("a", "A", false),
6346 option("b", "B", true)
6347 );
6348 let full = page.render();
6349 let fragment = page.render_island(0);
6350 for (path, html) in [("walk_to_html", &full), ("walk_island", &fragment)] {
6351 assert!(html.contains(&inner), "{path}: {html}");
6352 }
6353 assert!(
6354 full.starts_with("<!--f:i0--><form data-forma-island=\"0\""),
6355 "{full}"
6356 );
6357 }
6358
6359 fn branding_module() -> IrModule {
6366 let mut row = encode_open_tag(2, &[]);
6367 row.extend_from_slice(&encode_dyn_text(2, 1));
6368 row.extend_from_slice(&encode_close_tag(2));
6369 let mut ops = encode_island_start(0);
6370 ops.extend_from_slice(&encode_open_tag(0, &[]));
6371 ops.extend_from_slice(&encode_dyn_text(0, 0));
6372 ops.extend_from_slice(&encode_list(1, 2, &row));
6373 ops.extend_from_slice(&encode_close_tag(0));
6374 ops.extend_from_slice(&encode_island_end(0));
6375 let data = build_minimal_ir(
6376 &["div", "Branding", "i", "name", "colors", "color"],
6377 &[
6378 (0, 3, 0x01, 0x00, &[]),
6379 (1, 4, 0x04, 0x00, &[]),
6380 (2, 5, 0x01, 0x00, &[]),
6381 ],
6382 &ops,
6383 &[(0, 0x01, 0x01, 1, 0, &[])],
6384 );
6385 IrModule::parse(&data).unwrap()
6386 }
6387
6388 fn branding(name: &str, colors: usize) -> SlotData {
6389 let mut slots = SlotData::new(3);
6390 slots.set(0, SlotValue::Text(name.to_string()));
6391 slots.set(
6392 1,
6393 SlotValue::array(
6394 (0..colors)
6395 .map(|i| SlotValue::Text(format!("c{i}")))
6396 .collect(),
6397 ),
6398 );
6399 slots
6400 }
6401
6402 #[test]
6403 fn counted_walk_writes_what_walk_to_html_writes_and_counts_it() {
6404 let module = branding_module();
6405 let slots = branding("Acme", 3);
6406 let out = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap();
6407
6408 assert_eq!(out.html, walk_to_html(&module, &slots).unwrap());
6409 assert_eq!(out.counts.bytes, out.html.len());
6410 assert_eq!(out.counts.nodes, 8);
6413 assert_eq!(out.counts.list_iterations, 3);
6414 }
6415
6416 #[test]
6417 fn counted_walk_returns_the_counts_reached_when_a_budget_stops_it() {
6418 let module = branding_module();
6419
6420 let limits = WalkLimits {
6423 max_list_iterations: 5,
6424 ..WalkLimits::default()
6425 };
6426 let failure = walk_to_html_counted(&module, &branding("Acme", 9), limits).unwrap_err();
6427 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 5 });
6428 assert_eq!(failure.counts.list_iterations, 0);
6429 assert_eq!(failure.counts.nodes, 2);
6430 assert_eq!(failure.component_path, vec!["Branding".to_string()]);
6431 assert!(failure.counts.bytes > 0);
6432
6433 let limits = WalkLimits {
6436 max_output_bytes: 400,
6437 ..WalkLimits::default()
6438 };
6439 let failure = walk_to_html_counted(&module, &branding("Acme", 50), limits).unwrap_err();
6440 assert_eq!(failure.error, IrError::OutputLimitExceeded { limit: 400 });
6441 let counts = failure.counts;
6442 assert!(counts.bytes <= 400 && counts.bytes > 300, "{counts:?}");
6443 assert!(
6444 counts.list_iterations > 1 && counts.list_iterations < 50,
6445 "{counts:?}"
6446 );
6447 assert!(
6448 counts.nodes >= 2 + 2 * (counts.list_iterations - 1),
6449 "{counts:?}"
6450 );
6451 assert_eq!(failure.component_path, vec!["Branding".to_string()]);
6452 }
6453
6454 #[test]
6455 fn counted_walk_names_no_component_for_a_failure_outside_every_island() {
6456 let mut ops = encode_open_tag(0, &[]);
6458 ops.extend_from_slice(&encode_text(99));
6459 let data = build_minimal_ir(&["p"], &[], &ops, &[]);
6460 let module = IrModule::parse(&data).unwrap();
6461 let failure =
6462 walk_to_html_counted(&module, &SlotData::new(0), WalkLimits::default()).unwrap_err();
6463 assert_eq!(
6464 failure.error,
6465 IrError::StringIndexOutOfBounds { index: 99, len: 1 }
6466 );
6467 assert!(failure.component_path.is_empty());
6468 assert_eq!(failure.counts.nodes, 1);
6469 }
6470
6471 #[test]
6472 fn counted_walk_names_every_island_around_a_failure_outermost_first() {
6473 let mut ops = encode_island_start(0); ops.extend_from_slice(&encode_open_tag(0, &[]));
6478 let done_at = ops.len() as u32;
6479 ops.extend_from_slice(&encode_island_start(2)); ops.extend_from_slice(&encode_open_tag(0, &[]));
6481 ops.extend_from_slice(&encode_close_tag(0));
6482 ops.extend_from_slice(&encode_island_end(2));
6483 let card_at = ops.len() as u32;
6484 ops.extend_from_slice(&encode_island_start(1)); ops.extend_from_slice(&encode_open_tag(0, &[]));
6486 ops.extend_from_slice(&encode_text(99)); ops.extend_from_slice(&encode_close_tag(0));
6488 ops.extend_from_slice(&encode_island_end(1));
6489 ops.extend_from_slice(&encode_close_tag(0));
6490 ops.extend_from_slice(&encode_island_end(0));
6491 let data = build_minimal_ir(
6492 &["div", "Shell", "Card", "Done", "x"],
6493 &[],
6494 &ops,
6495 &[
6496 (0, 0x01, 0x01, 1, 0, &[]),
6497 (1, 0x01, 0x01, 2, card_at, &[]),
6498 (2, 0x01, 0x01, 3, done_at, &[]),
6499 ],
6500 );
6501 let module = IrModule::parse(&data).unwrap();
6502 let failure =
6503 walk_to_html_counted(&module, &SlotData::new(0), WalkLimits::default()).unwrap_err();
6504 assert_eq!(
6505 failure.component_path,
6506 vec!["Shell".to_string(), "Card".to_string()]
6507 );
6508 }
6509
6510 #[test]
6511 fn the_fault_trigger_is_armed_only_by_the_counted_walk() {
6512 let module = branding_module();
6516 let slots = branding(crate::fault_injection::FAULT_TRIGGER, 1);
6517 let html = walk_to_html(&module, &slots).expect("walk_to_html never arms it");
6518 assert!(html.contains("forma-test-fault"), "{html}");
6519 let failure = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap_err();
6520 assert!(matches!(failure.error, IrError::Panicked { .. }));
6521 }
6522
6523 #[test]
6524 fn counted_walk_catches_a_panic_and_names_its_island() {
6525 let module = branding_module();
6529 let slots = branding(crate::fault_injection::FAULT_TRIGGER, 2);
6530 let failure = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap_err();
6531 match &failure.error {
6532 IrError::Panicked { message } => {
6533 assert!(message.contains("stand-in"), "{message}")
6534 }
6535 other => panic!("expected Panicked, got {other:?}"),
6536 }
6537 assert_eq!(failure.component_path, vec!["Branding".to_string()]);
6538 let out =
6540 walk_to_html_counted(&module, &branding("Acme", 2), WalkLimits::default()).unwrap();
6541 assert!(out.html.contains("Acme"));
6542 }
6543
6544 fn lists_module(lists: usize, nested: bool) -> IrModule {
6550 let mut ops = encode_open_tag(0, &[]);
6551 let mut slots: Vec<(u16, u32, u8, u8, &[u8])> = Vec::new();
6552 for k in 0..lists as u16 {
6553 let (list_slot, item_slot) = (2 * k, 2 * k + 1);
6554 let mut row = encode_open_tag(1, &[]);
6555 if nested {
6556 let inner_item = 2 * lists as u16;
6559 let mut inner_row = encode_open_tag(1, &[]);
6560 inner_row.extend_from_slice(&encode_close_tag(1));
6561 row.extend_from_slice(&encode_list(item_slot, inner_item, &inner_row));
6562 }
6563 row.extend_from_slice(&encode_close_tag(1));
6564 ops.extend_from_slice(&encode_list(list_slot, item_slot, &row));
6565 slots.push((list_slot, 2, 0x04, 0x00, &[]));
6566 slots.push((item_slot, 3, if nested { 0x04 } else { 0x01 }, 0x00, &[]));
6567 }
6568 if nested {
6569 slots.push((2 * lists as u16, 3, 0x01, 0x00, &[]));
6570 }
6571 ops.extend_from_slice(&encode_close_tag(0));
6572 let data = build_minimal_ir(&["div", "i", "items", "item"], &slots, &ops, &[]);
6573 IrModule::parse(&data).unwrap()
6574 }
6575
6576 fn rows(n: usize) -> SlotValue {
6577 SlotValue::array((0..n).map(|_| SlotValue::Text("x".into())).collect())
6578 }
6579
6580 fn limits(max_list_iterations: usize) -> WalkLimits {
6581 WalkLimits {
6582 max_list_iterations,
6583 ..WalkLimits::default()
6584 }
6585 }
6586
6587 #[test]
6588 fn a_list_longer_than_the_remaining_budget_fails_before_its_first_row() {
6589 let module = lists_module(1, false);
6591 let mut slots = SlotData::new(2);
6592 slots.set(0, rows(51));
6593 let failure = walk_to_html_counted(&module, &slots, limits(50)).unwrap_err();
6594 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 50 });
6595 assert_eq!(failure.counts.list_iterations, 0);
6596 assert_eq!(failure.counts.nodes, 1, "only the wrapping div was written");
6597
6598 slots.set(0, rows(50));
6600 let out = walk_to_html_counted(&module, &slots, limits(50)).unwrap();
6601 assert_eq!(out.counts.list_iterations, 50);
6602 assert_eq!(out.html.matches("<i>").count(), 50);
6603 }
6604
6605 #[test]
6606 fn a_list_exactly_at_the_remaining_budget_still_renders() {
6607 let module = lists_module(2, false);
6610 let mut slots = SlotData::new(4);
6611 slots.set(0, rows(20));
6612 slots.set(2, rows(30));
6613 let out = walk_to_html_counted(&module, &slots, limits(50)).unwrap();
6614 assert_eq!(out.counts.list_iterations, 50);
6615 assert_eq!(out.html.matches("<i>").count(), 50);
6616
6617 slots.set(2, rows(31));
6618 let failure = walk_to_html_counted(&module, &slots, limits(50)).unwrap_err();
6619 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 50 });
6620 assert_eq!(
6621 failure.counts.list_iterations, 20,
6622 "the first list, and no more"
6623 );
6624 assert_eq!(failure.counts.nodes, 1 + 20);
6625 }
6626
6627 #[test]
6628 fn a_nested_list_is_checked_against_what_its_parents_left() {
6629 let module = lists_module(1, true);
6634 let mut slots = SlotData::new(3);
6635 slots.set(0, SlotValue::array((0..3).map(|_| rows(20)).collect()));
6636 let failure = walk_to_html_counted(&module, &slots, limits(59)).unwrap_err();
6637 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 59 });
6638 assert_eq!(failure.counts.list_iterations, 43);
6639
6640 let out = walk_to_html_counted(&module, &slots, limits(63)).unwrap();
6642 assert_eq!(out.counts.list_iterations, 63);
6643 assert!(walk_to_html_counted(&module, &slots, limits(62)).is_err());
6644 }
6645
6646 #[test]
6647 fn the_per_row_check_still_stops_a_list_whose_nested_rows_spend_the_budget() {
6648 let module = lists_module(1, true);
6652 let mut slots = SlotData::new(3);
6653 slots.set(0, SlotValue::array((0..3).map(|_| rows(11)).collect()));
6654 let failure = walk_to_html_counted(&module, &slots, limits(24)).unwrap_err();
6655 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 24 });
6656 assert_eq!(failure.counts.list_iterations, 24);
6657 }
6658}