1#[cfg(feature = "tracing")]
8use crate::format::SlotSource;
9use crate::format::{IrError, IslandTrigger, Opcode};
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
463#[allow(dead_code)] pub(crate) fn walk_island(
488 module: &IrModule,
489 slots: &SlotData,
490 island_id: u16,
491) -> Result<String, IrError> {
492 walk_island_with_limits(module, slots, island_id, WalkLimits::default())
493}
494
495#[allow(dead_code)]
497pub(crate) fn walk_island_with_limits(
498 module: &IrModule,
499 slots: &SlotData,
500 island_id: u16,
501 limits: WalkLimits,
502) -> Result<String, IrError> {
503 let entry = module
505 .island_entry(island_id)
506 .ok_or(IrError::IslandNotFound(island_id))?;
507
508 let byte_offset = entry.byte_offset as usize;
509 let ops = &module.opcodes;
510
511 if byte_offset >= ops.len() {
514 return Err(IrError::BufferTooShort {
515 expected: byte_offset.saturating_add(1),
516 actual: ops.len(),
517 });
518 }
519
520 let opcode = Opcode::from_byte(ops[byte_offset])?;
522 if opcode != Opcode::IslandStart {
523 return Err(IrError::InvalidOpcode(ops[byte_offset]));
524 }
525
526 let content_start = byte_offset.saturating_add(3);
528 if content_start > ops.len() {
529 return Err(IrError::BufferTooShort {
530 expected: content_start,
531 actual: ops.len(),
532 });
533 }
534
535 let mut out = String::with_capacity(256);
539 let mut state = WalkState::new(limits);
540 state.force_inline_props = true;
543 let mut slots_mut = slots.clone();
544
545 let component_name = module.strings.get(entry.name_str_idx)?.to_string();
546 let inline_props = if entry.slot_ids.is_empty() {
547 None
548 } else {
549 let props = build_island_props(module, &slots_mut, &entry.slot_ids);
550 Some(serde_json::to_string(&props).unwrap_or_else(|_| "{}".to_string()))
551 };
552 state.pending_island = Some(IslandPending {
553 id: island_id,
554 component_name,
555 inline_props,
556 trigger: entry.trigger,
557 });
558
559 walk_range_until_island_end(
561 module,
562 &mut slots_mut,
563 ops,
564 content_start,
565 island_id,
566 &mut out,
567 &mut state,
568 0,
569 )?;
570
571 Ok(out)
572}
573
574const MAX_LIST_DEPTH: u8 = crate::format::MAX_LIST_DEPTH as u8;
581
582const MAX_RECURSION_DEPTH: usize = crate::format::MAX_CONTROL_DEPTH;
585
586enum WalkMode {
588 Range { end: usize },
592 UntilIslandEnd { target_island_id: u16 },
595}
596
597#[allow(clippy::too_many_arguments)]
604fn walk_range(
605 module: &IrModule,
606 slots: &mut SlotData,
607 ops: &[u8],
608 start: usize,
609 end: usize,
610 out: &mut String,
611 state: &mut WalkState,
612 depth: usize,
613) -> Result<(), IrError> {
614 walk_range_impl(
615 module,
616 slots,
617 ops,
618 start,
619 WalkMode::Range { end },
620 out,
621 state,
622 depth,
623 )
624}
625
626#[allow(clippy::too_many_arguments)]
633fn walk_range_until_island_end(
634 module: &IrModule,
635 slots: &mut SlotData,
636 ops: &[u8],
637 start: usize,
638 target_island_id: u16,
639 out: &mut String,
640 state: &mut WalkState,
641 depth: usize,
642) -> Result<(), IrError> {
643 walk_range_impl(
644 module,
645 slots,
646 ops,
647 start,
648 WalkMode::UntilIslandEnd { target_island_id },
649 out,
650 state,
651 depth,
652 )
653}
654
655#[allow(clippy::too_many_arguments)]
670fn walk_range_impl(
671 module: &IrModule,
672 slots: &mut SlotData,
673 ops: &[u8],
674 start: usize,
675 mode: WalkMode,
676 out: &mut String,
677 state: &mut WalkState,
678 depth: usize,
679) -> Result<(), IrError> {
680 if depth > MAX_RECURSION_DEPTH {
681 return Err(IrError::RecursionLimitExceeded);
682 }
683
684 let strings = &module.strings;
685 let mut pos = start;
686 let limit = state.max_output_bytes;
687 let loop_bound = match mode {
699 WalkMode::Range { end } => end,
700 WalkMode::UntilIslandEnd { .. } => usize::MAX, };
702
703 let region_limit = match mode {
707 WalkMode::Range { end } => end.min(ops.len()),
708 WalkMode::UntilIslandEnd { .. } => ops.len(),
709 };
710
711 while pos < loop_bound {
712 if out.len() > limit {
715 return Err(IrError::OutputLimitExceeded { limit });
716 }
717
718 if pos >= ops.len() {
721 match mode {
722 WalkMode::Range { .. } => {
723 return Err(IrError::BufferTooShort {
724 expected: pos + 1,
725 actual: ops.len(),
726 });
727 }
728 WalkMode::UntilIslandEnd { .. } => break, }
730 }
731
732 let opcode = Opcode::from_byte(ops[pos])?;
733 pos += 1; if state.pending_tag_close && opcode != Opcode::DynAttr {
738 close_start_tag(out, state, limit)?;
739 }
740
741 match opcode {
742 Opcode::OpenTag | Opcode::VoidTag => {
744 let (tag_str_idx, attrs, new_pos) = read_tag_with_attrs(ops, pos, strings)?;
745 let tag = strings.get(tag_str_idx)?;
746 let has_content = opcode == Opcode::OpenTag;
747 emit_open_tag(out, tag, &attrs, has_content, state, limit)?;
748 state.nodes += 1;
749 pos = new_pos;
750 }
751
752 Opcode::CloseTag => {
753 let str_idx = read_u32(ops, pos)?;
754 pos += 4;
755 let tag = strings.get(str_idx)?;
756 if !is_valid_name(tag) {
759 return Err(IrError::InvalidName(tag.to_string()));
760 }
761 leave_element(out, tag, state, limit)?;
762 push_checked(out, "</", limit)?;
763 push_checked(out, tag, limit)?;
764 push_checked(out, ">", limit)?;
765 }
766
767 Opcode::Text => {
768 let str_idx = read_u32(ops, pos)?;
769 pos += 4;
770 let text = strings.get(str_idx)?;
771 if let Some(parent) = state.text_only.as_mut() {
772 if !parent.value_bound {
773 write_text_only(out, parent, text, limit)?;
774 }
775 } else {
776 push_escaped_html(out, text, limit)?;
777 if let Some(option) = state.option_by_text.as_mut() {
778 option.text.push_str(text);
779 }
780 }
781 state.nodes += 1;
782 }
783
784 Opcode::DynText => {
785 let slot_id = read_u16(ops, pos)?;
787 pos += 2;
788 let marker_id = read_u16(ops, pos)?;
789 pos += 2;
790
791 check_slot_source(module, slot_id, slots.get(slot_id));
792 let value = slots.get(slot_id).to_text();
793
794 #[cfg(any(test, feature = "__fault-injection"))]
798 #[allow(clippy::panic)]
799 if state.fault_armed && value == crate::fault_injection::FAULT_TRIGGER {
800 panic!("stand-in component panicked (FAULT_TRIGGER)");
801 }
802
803 if let Some(parent) = state.text_only.as_mut() {
804 if !parent.value_bound {
807 write_text_only(out, parent, &value, limit)?;
808 }
809 } else {
810 push_checked(out, "<!--f:t", limit)?;
811 push_u16(out, marker_id, limit)?;
812 push_checked(out, "-->", limit)?;
813 let written = if value.is_empty() {
819 "\u{200B}"
820 } else {
821 value.as_str()
822 };
823 push_escaped_html(out, written, limit)?;
824 push_checked(out, "<!--/f:t", limit)?;
825 push_u16(out, marker_id, limit)?;
826 push_checked(out, "-->", limit)?;
827 if let Some(option) = state.option_by_text.as_mut() {
828 option.text.push_str(written);
829 }
830 }
831 state.nodes += 1;
832 }
833
834 Opcode::DynAttr => {
835 let attr_str_idx = read_u32(ops, pos)?;
837 let slot_id = read_u16(ops, pos + 4)?;
838 pos += 6;
839 check_slot_source(module, slot_id, slots.get(slot_id));
840
841 if state.pending_tag_close {
842 let attr_name = strings.get(attr_str_idx)?;
843 if consume_control_attr(state, attr_name, Prop::Slot(slots.get(slot_id))) {
846 continue;
847 }
848 let text;
851 let value = match slots.get(slot_id) {
852 SlotValue::Bool(false) | SlotValue::Null => None,
858 SlotValue::Bool(true) => Some(AttrValue::Bare),
859 other => {
860 text = other.to_text();
861 Some(AttrValue::Valued(text.as_str()))
866 }
867 };
868 if let Some(value) = value {
869 let tag = std::mem::take(&mut state.pending_tag_name);
873 emit_attr(out, &tag, attr_name, value, limit)?;
874 state.pending_tag_name = tag;
875 }
876 }
877 }
879
880 Opcode::IslandStart => {
881 let island_id = read_u16(ops, pos)?;
883 pos += 2;
884
885 if let Some(outer) = &state.pending_island {
892 return Err(IrError::NestedIslandRoot {
893 outer: outer.id,
894 inner: island_id,
895 });
896 }
897 state.islands.push(island_id);
898
899 if state.text_only.is_none() {
901 push_checked(out, "<!--f:i", limit)?;
902 push_u16(out, island_id, limit)?;
903 push_checked(out, "-->", limit)?;
904 }
905
906 if let Some(entry) = module.island_entry(island_id) {
912 let component_name = strings.get(entry.name_str_idx)?.to_string();
913
914 let inline_props = if entry.slot_ids.is_empty() {
916 None
917 } else {
918 let props = build_island_props(module, slots, &entry.slot_ids);
919 let json =
920 serde_json::to_string(&props).unwrap_or_else(|_| "{}".to_string());
921 if json.len() > INLINE_PROPS_MAX_BYTES && !state.force_inline_props {
922 state
923 .script_tag_props
924 .insert(island_id, serde_json::Value::Object(props));
925 None
926 } else {
927 Some(json)
928 }
929 };
930
931 state.pending_island = Some(IslandPending {
932 id: island_id,
933 component_name,
934 inline_props,
935 trigger: entry.trigger,
936 });
937 } else {
938 #[cfg(feature = "tracing")]
939 tracing::warn!(
940 island_id = island_id,
941 "ISLAND_START names an island that is not in the island table — \
942 markers emitted but no data-forma-* attributes, so it will not hydrate \
943 (module was not validated)"
944 );
945 }
946 }
947
948 Opcode::IslandEnd => {
949 let island_id = read_u16(ops, pos)?;
951 pos += 2;
952 if state.islands.last() == Some(&island_id) {
953 state.islands.pop();
954 }
955
956 if state.pending_island.take().is_some() {
962 #[cfg(feature = "tracing")]
963 tracing::warn!(
964 island_id = island_id,
965 "ISLAND_START was not followed by an element — island attributes dropped, \
966 this island will not hydrate (compiler should emit a shell element)"
967 );
968 }
969
970 if let WalkMode::UntilIslandEnd { target_island_id } = mode {
972 if island_id == target_island_id {
973 if state.pending_tag_close {
975 close_start_tag(out, state, limit)?;
976 }
977 return Ok(());
978 }
979 }
980
981 if state.text_only.is_none() {
985 push_checked(out, "<!--/f:i", limit)?;
986 push_u16(out, island_id, limit)?;
987 push_checked(out, "-->", limit)?;
988 }
989 }
990
991 Opcode::ShowIf => {
992 let slot_id = read_u16(ops, pos)?;
994 let then_len = read_u32(ops, pos + 2)? as usize;
995 let else_len = read_u32(ops, pos + 6)? as usize;
996 pos += 10;
997
998 check_slot_source(module, slot_id, slots.get(slot_id));
999 let condition = slots.get(slot_id).as_bool();
1000 let then_start = pos;
1001 let then_end = bounded_offset(pos, then_len, region_limit)?;
1005 if then_end >= region_limit || ops[then_end] != Opcode::ShowElse as u8 {
1009 return Err(IrError::InvalidSection);
1010 }
1011 let else_start = then_end + 1; let else_end = bounded_offset(else_start, else_len, region_limit)?;
1013
1014 let markers = state.text_only.is_none();
1017 if markers {
1018 push_checked(out, "<!--f:s", limit)?;
1019 push_u16(out, slot_id, limit)?;
1020 push_checked(out, "-->", limit)?;
1021 }
1022
1023 if condition {
1024 walk_range(
1025 module,
1026 slots,
1027 ops,
1028 then_start,
1029 then_end,
1030 out,
1031 state,
1032 depth + 1,
1033 )?;
1034 } else {
1035 walk_range(
1036 module,
1037 slots,
1038 ops,
1039 else_start,
1040 else_end,
1041 out,
1042 state,
1043 depth + 1,
1044 )?;
1045 }
1046
1047 if markers {
1048 push_checked(out, "<!--/f:s", limit)?;
1049 push_u16(out, slot_id, limit)?;
1050 push_checked(out, "-->", limit)?;
1051 }
1052
1053 pos = else_end;
1055 }
1056
1057 Opcode::ShowElse => {
1058 }
1062
1063 Opcode::List => {
1074 let slot_id = read_u16(ops, pos)?;
1076 let item_slot_id = read_u16(ops, pos + 2)?;
1077 let body_len = read_u32(ops, pos + 4)? as usize;
1078 pos += 8;
1079
1080 check_slot_source(module, slot_id, slots.get(slot_id));
1081
1082 let body_start = pos;
1083 let body_end = bounded_offset(pos, body_len, region_limit)?;
1084
1085 let list_marker_id = slot_id;
1088 let markers = state.text_only.is_none();
1089 if markers {
1090 push_checked(out, "<!--f:l", limit)?;
1091 push_u16(out, list_marker_id, limit)?;
1092 push_checked(out, "-->", limit)?;
1093 }
1094
1095 let items = match slots.get(slot_id) {
1101 SlotValue::Array(a) => Some(a.clone()),
1102 _ => None,
1103 };
1104
1105 if let Some(items) = items.filter(|a| !a.is_empty()) {
1106 state.list_depth += 1;
1107 if state.list_depth > MAX_LIST_DEPTH {
1108 return Err(IrError::ListDepthExceeded {
1109 max: MAX_LIST_DEPTH,
1110 });
1111 }
1112 let remaining = state
1125 .max_list_iterations
1126 .saturating_sub(state.list_iterations);
1127 if items.len() > remaining {
1128 return Err(IrError::ListBudgetExceeded {
1129 max: state.max_list_iterations,
1130 });
1131 }
1132 for item in items.iter() {
1133 if state.list_iterations >= state.max_list_iterations {
1140 return Err(IrError::ListBudgetExceeded {
1141 max: state.max_list_iterations,
1142 });
1143 }
1144 state.list_iterations += 1;
1145 let mark = state.slot_journal.len();
1150 let previous = slots.replace(item_slot_id, item.clone());
1151 state.slot_journal.push((item_slot_id, previous));
1152
1153 let walked = walk_range(
1154 module,
1155 slots,
1156 ops,
1157 body_start,
1158 body_end,
1159 out,
1160 state,
1161 depth + 1,
1162 );
1163 state.unwind_slot_journal(slots, mark);
1167 walked?;
1168
1169 state.pending_list_key = None;
1174 }
1175 state.list_depth -= 1;
1176 }
1177 state.pending_list_key = None;
1178
1179 if markers {
1180 push_checked(out, "<!--/f:l", limit)?;
1181 push_u16(out, list_marker_id, limit)?;
1182 push_checked(out, "-->", limit)?;
1183 }
1184
1185 pos = body_end;
1186 }
1187
1188 Opcode::Prop => {
1189 let src_slot_id = read_u16(ops, pos)?;
1191 let prop_str_idx = read_u32(ops, pos + 2)?;
1192 let target_slot_id = read_u16(ops, pos + 6)?;
1193 pos += 8;
1194
1195 let prop_name = strings.get(prop_str_idx)?;
1196 let value = slots.get(src_slot_id).get_property(prop_name);
1197 let previous = slots.replace(target_slot_id, value);
1202 state.journal_write(target_slot_id, previous);
1203 }
1204 }
1205 }
1206
1207 match mode {
1209 WalkMode::Range { .. } => {
1210 if state.pending_tag_close {
1220 close_start_tag(out, state, limit)?;
1221 }
1222 Ok(())
1223 }
1224 WalkMode::UntilIslandEnd { target_island_id } => {
1225 Err(IrError::IslandNotFound(target_island_id))
1228 }
1229 }
1230}
1231
1232fn build_island_props(
1248 module: &IrModule,
1249 slots: &SlotData,
1250 slot_ids: &[u16],
1251) -> serde_json::Map<String, serde_json::Value> {
1252 let mut map = serde_json::Map::new();
1253 for &slot_id in slot_ids {
1254 if let Some(entry) = module.slot_entry(slot_id) {
1256 if let Ok(name) = module.strings.get(entry.name_str_idx) {
1257 if map.contains_key(name) {
1258 #[cfg(feature = "tracing")]
1259 tracing::warn!(
1260 slot_id = slot_id,
1261 name = name,
1262 "Island declares two slots with the same name — keeping the first, \
1263 the later value is not reachable from the props object"
1264 );
1265 continue;
1266 }
1267 let value = slots.get(slot_id).to_json();
1268 map.insert(name.to_string(), value);
1269 }
1270 } else {
1271 #[cfg(feature = "tracing")]
1276 tracing::warn!(
1277 slot_id = slot_id,
1278 "Island references a slot id that is not in the slot table — prop dropped"
1279 );
1280 }
1281 }
1282 map
1283}
1284
1285fn bounded_offset(base: usize, len: usize, limit: usize) -> Result<usize, IrError> {
1294 base.checked_add(len)
1295 .filter(|e| *e <= limit)
1296 .ok_or(IrError::InvalidSection)
1297}
1298
1299fn is_valid_name(name: &str) -> bool {
1313 let mut chars = name.chars();
1314 match chars.next() {
1315 Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':' => {}
1316 _ => return false,
1317 }
1318 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'))
1319}
1320
1321fn is_event_handler_attr(name: &str) -> bool {
1323 name.len() >= 2 && name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
1324}
1325
1326const URL_ATTRS: [&str; 8] = [
1334 "href",
1335 "src",
1336 "action",
1337 "formaction",
1338 "xlink:href",
1339 "poster",
1340 "background",
1341 "data",
1342];
1343
1344const IMAGE_CONTEXT_TAGS: [&str; 13] = [
1348 "img", "image", "video", "audio", "source", "body", "table", "thead", "tbody", "tfoot", "tr",
1349 "td", "th",
1350];
1351
1352fn is_url_attr(name: &str) -> bool {
1354 URL_ATTRS.iter().any(|a| name.eq_ignore_ascii_case(a))
1355}
1356
1357fn is_dangerous_url(value: &str, tag: &str) -> bool {
1366 let normalized: String = value
1367 .chars()
1368 .filter(|c| {
1369 let n = *c as u32;
1370 !((n <= 0x20) || (0x7F..=0x9F).contains(&n))
1371 })
1372 .collect();
1373 let lower = normalized.to_ascii_lowercase();
1374
1375 if lower.starts_with("javascript") || lower.starts_with("vbscript") {
1376 return true;
1377 }
1378 if lower.starts_with("data:text/html") {
1379 return true;
1380 }
1381 if lower.starts_with("data:image/svg+xml") {
1386 return !IMAGE_CONTEXT_TAGS
1387 .iter()
1388 .any(|t| tag.eq_ignore_ascii_case(t));
1389 }
1390 false
1391}
1392
1393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1400enum AttrValue<'a> {
1401 Bare,
1403 Valued(&'a str),
1405}
1406
1407fn emit_attr(
1414 out: &mut String,
1415 tag: &str,
1416 key: &str,
1417 val: AttrValue<'_>,
1418 limit: usize,
1419) -> Result<(), IrError> {
1420 if !is_valid_name(key) {
1421 #[cfg(feature = "tracing")]
1425 tracing::warn!(attr = key, "Dropping attribute with an unsafe name");
1426 return Ok(());
1427 }
1428 if is_event_handler_attr(key) {
1429 #[cfg(feature = "tracing")]
1430 tracing::warn!(attr = key, "Dropping event-handler attribute from IR");
1431 return Ok(());
1432 }
1433 if let AttrValue::Valued(v) = val {
1434 if is_url_attr(key) && is_dangerous_url(v, tag) {
1435 #[cfg(feature = "tracing")]
1436 tracing::warn!(attr = key, "Dropping URL attribute with a dangerous scheme");
1437 return Ok(());
1438 }
1439 }
1440
1441 push_checked(out, " ", limit)?;
1442 push_checked(out, key, limit)?;
1443 if let AttrValue::Valued(v) = val {
1444 push_checked(out, "=\"", limit)?;
1445 push_escaped_attr(out, v, limit)?;
1446 push_checked(out, "\"", limit)?;
1447 }
1448 Ok(())
1449}
1450
1451fn emit_open_tag(
1457 out: &mut String,
1458 tag: &str,
1459 attrs: &[(&str, &str)],
1460 has_content: bool,
1461 state: &mut WalkState,
1462 limit: usize,
1463) -> Result<(), IrError> {
1464 if !is_valid_name(tag) {
1465 return Err(IrError::InvalidName(tag.to_string()));
1466 }
1467 if has_content {
1468 enter_element(out, tag, state, limit)?;
1469 }
1470 push_checked(out, "<", limit)?;
1471 push_checked(out, tag, limit)?;
1472
1473 for (key, val) in attrs {
1474 if consume_control_attr(state, key, Prop::Static(val)) {
1477 continue;
1478 }
1479 let value = if val.is_empty() {
1483 AttrValue::Bare
1484 } else {
1485 AttrValue::Valued(val)
1486 };
1487 emit_attr(out, tag, key, value, limit)?;
1488 }
1489
1490 if let Some(island) = state.pending_island.take() {
1492 push_checked(out, " data-forma-island=\"", limit)?;
1493 push_u16(out, island.id, limit)?;
1494 push_checked(out, "\" data-forma-component=\"", limit)?;
1495 push_escaped_attr(out, &island.component_name, limit)?;
1496 push_checked(out, "\" data-forma-status=\"pending\"", limit)?;
1497 let trigger_str = match island.trigger {
1498 IslandTrigger::Load => "load",
1499 IslandTrigger::Visible => "visible",
1500 IslandTrigger::Interaction => "interaction",
1501 IslandTrigger::Idle => "idle",
1502 };
1503 push_checked(out, " data-forma-hydrate=\"", limit)?;
1504 push_checked(out, trigger_str, limit)?;
1505 push_checked(out, "\"", limit)?;
1506 if let Some(ref props_json) = island.inline_props {
1507 push_checked(out, " data-forma-props=\"", limit)?;
1508 push_escaped_attr(out, props_json, limit)?;
1509 push_checked(out, "\"", limit)?;
1510 }
1511 }
1512
1513 if let Some(key) = state.pending_list_key.take() {
1515 push_checked(out, " data-forma-key=\"", limit)?;
1516 push_escaped_attr(out, &key, limit)?;
1517 push_checked(out, "\"", limit)?;
1518 }
1519
1520 state.pending_tag_close = true;
1522 state.pending_tag_name.clear();
1523 state.pending_tag_name.push_str(tag);
1524 Ok(())
1525}
1526
1527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1561enum TextOnlyTag {
1562 Textarea,
1563 Title,
1564 Style,
1565 Script,
1566}
1567
1568impl TextOnlyTag {
1569 fn from_tag(tag: &str) -> Option<Self> {
1570 [Self::Textarea, Self::Title, Self::Style, Self::Script]
1571 .into_iter()
1572 .find(|t| tag.eq_ignore_ascii_case(t.name()))
1573 }
1574
1575 fn name(self) -> &'static str {
1576 match self {
1577 TextOnlyTag::Textarea => "textarea",
1578 TextOnlyTag::Title => "title",
1579 TextOnlyTag::Style => "style",
1580 TextOnlyTag::Script => "script",
1581 }
1582 }
1583}
1584
1585#[derive(Debug)]
1587struct TextOnlyParent {
1588 tag: TextOnlyTag,
1589 started: bool,
1592 value_bound: bool,
1595}
1596
1597fn write_text_only(
1607 out: &mut String,
1608 parent: &mut TextOnlyParent,
1609 text: &str,
1610 limit: usize,
1611) -> Result<(), IrError> {
1612 if text.is_empty() {
1613 return Ok(());
1614 }
1615 if parent.tag == TextOnlyTag::Textarea
1616 && !parent.started
1617 && (text.starts_with('\n') || text.starts_with('\r'))
1618 {
1619 push_checked(out, "\n", limit)?;
1620 }
1621 parent.started = true;
1622 push_escaped_html(out, text, limit)
1623}
1624
1625#[derive(Debug, Default)]
1634enum PendingControl {
1635 #[default]
1636 None,
1637 Textarea { value: Option<String> },
1639 Select {
1641 value: Option<SlotValue>,
1642 multiple: bool,
1643 },
1644 OptionTag { value: Option<String> },
1647}
1648
1649#[derive(Debug)]
1664struct SelectBinding {
1665 wanted: SelectWanted,
1666 matched: bool,
1668}
1669
1670#[derive(Debug)]
1671enum SelectWanted {
1672 One(String),
1673 Any(Vec<String>),
1674}
1675
1676impl SelectBinding {
1677 fn new(value: &SlotValue, multiple: bool) -> Self {
1678 let wanted = match value {
1679 SlotValue::Array(items) if multiple => {
1680 SelectWanted::Any(items.iter().map(js_string).collect())
1681 }
1682 other => SelectWanted::One(other.to_text()),
1684 };
1685 SelectBinding {
1686 wanted,
1687 matched: false,
1688 }
1689 }
1690
1691 fn selects(&mut self, option_value: &str) -> bool {
1693 match &self.wanted {
1694 SelectWanted::Any(values) => values.iter().any(|v| v == option_value),
1695 SelectWanted::One(value) => {
1696 let hit = !self.matched && value == option_value;
1697 self.matched |= hit;
1698 hit
1699 }
1700 }
1701 }
1702}
1703
1704fn js_string(value: &SlotValue) -> String {
1707 match value {
1708 SlotValue::Null => "null".to_string(),
1709 other => other.to_text(),
1710 }
1711}
1712
1713#[derive(Debug)]
1716struct OptionByText {
1717 insert_at: usize,
1720 text: String,
1722}
1723
1724#[derive(Clone, Copy)]
1726enum Prop<'a> {
1727 Static(&'a str),
1728 Slot(&'a SlotValue),
1729}
1730
1731impl Prop<'_> {
1732 fn control_text(self) -> String {
1734 match self {
1735 Prop::Static(s) => s.to_string(),
1736 Prop::Slot(v) => v.to_text(),
1737 }
1738 }
1739
1740 fn is_written(self) -> bool {
1743 !matches!(
1744 self,
1745 Prop::Slot(SlotValue::Null) | Prop::Slot(SlotValue::Bool(false))
1746 )
1747 }
1748
1749 fn written(self) -> Option<String> {
1752 match self {
1753 _ if !self.is_written() => None,
1754 Prop::Slot(SlotValue::Bool(true)) => Some(String::new()),
1755 other => Some(other.control_text()),
1756 }
1757 }
1758
1759 fn to_slot(self) -> SlotValue {
1760 match self {
1761 Prop::Static(s) => SlotValue::Text(s.to_string()),
1762 Prop::Slot(v) => v.clone(),
1763 }
1764 }
1765}
1766
1767fn consume_control_attr(state: &mut WalkState, key: &str, prop: Prop<'_>) -> bool {
1772 match &mut state.pending_control {
1773 PendingControl::None => false,
1774 PendingControl::Textarea { value } => {
1775 if key != "value" {
1776 return false;
1777 }
1778 *value = Some(prop.control_text());
1779 true
1780 }
1781 PendingControl::Select { value, multiple } => match key {
1782 "value" => {
1783 *value = Some(prop.to_slot());
1784 true
1785 }
1786 "multiple" => {
1787 *multiple |= prop.is_written();
1788 false
1789 }
1790 _ => false,
1791 },
1792 PendingControl::OptionTag { value } => match key {
1793 "selected" => true,
1796 "value" => {
1797 if value.is_none() {
1799 *value = prop.written();
1800 }
1801 false
1802 }
1803 _ => false,
1804 },
1805 }
1806}
1807
1808fn enter_element(
1811 out: &mut String,
1812 tag: &str,
1813 state: &mut WalkState,
1814 limit: usize,
1815) -> Result<(), IrError> {
1816 let is = |name: &str| tag.eq_ignore_ascii_case(name);
1817 if is("option") || is("optgroup") {
1819 finish_option_by_text(out, state, limit)?;
1820 }
1821 state.pending_control = if is("textarea") {
1822 PendingControl::Textarea { value: None }
1823 } else if is("select") {
1824 PendingControl::Select {
1825 value: None,
1826 multiple: false,
1827 }
1828 } else if is("option") && state.select.is_some() {
1829 PendingControl::OptionTag { value: None }
1830 } else {
1831 PendingControl::None
1832 };
1833 if state.text_only.is_none() {
1834 state.text_only = TextOnlyTag::from_tag(tag).map(|tag| TextOnlyParent {
1835 tag,
1836 started: false,
1837 value_bound: false,
1838 });
1839 }
1840 Ok(())
1841}
1842
1843fn leave_element(
1845 out: &mut String,
1846 tag: &str,
1847 state: &mut WalkState,
1848 limit: usize,
1849) -> Result<(), IrError> {
1850 let is = |name: &str| tag.eq_ignore_ascii_case(name);
1851 if is("option") || is("optgroup") || is("select") {
1852 finish_option_by_text(out, state, limit)?;
1853 }
1854 if is("select") {
1855 state.select = None;
1856 }
1857 if state
1858 .text_only
1859 .as_ref()
1860 .is_some_and(|parent| is(parent.tag.name()))
1861 {
1862 state.text_only = None;
1863 }
1864 Ok(())
1865}
1866
1867fn close_start_tag(out: &mut String, state: &mut WalkState, limit: usize) -> Result<(), IrError> {
1873 state.pending_tag_close = false;
1874 match std::mem::take(&mut state.pending_control) {
1875 PendingControl::None => {}
1876 PendingControl::Textarea { value } => {
1877 push_checked(out, ">", limit)?;
1878 if let (Some(value), Some(parent)) = (value, state.text_only.as_mut()) {
1879 if parent.tag == TextOnlyTag::Textarea {
1880 parent.value_bound = true;
1881 write_text_only(out, parent, &value, limit)?;
1882 }
1883 }
1884 return Ok(());
1885 }
1886 PendingControl::Select { value, multiple } => {
1887 state.select = value.map(|value| SelectBinding::new(&value, multiple));
1888 }
1889 PendingControl::OptionTag { value: Some(value) } => {
1890 if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
1891 push_checked(out, " selected", limit)?;
1892 }
1893 }
1894 PendingControl::OptionTag { value: None } => {
1895 state.option_by_text = Some(OptionByText {
1896 insert_at: out.len(),
1897 text: String::new(),
1898 });
1899 }
1900 }
1901 push_checked(out, ">", limit)
1902}
1903
1904fn finish_option_by_text(
1909 out: &mut String,
1910 state: &mut WalkState,
1911 limit: usize,
1912) -> Result<(), IrError> {
1913 let Some(option) = state.option_by_text.take() else {
1914 return Ok(());
1915 };
1916 let value = strip_and_collapse_ascii_whitespace(&option.text);
1917 if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
1918 const SELECTED: &str = " selected";
1919 if out.len().saturating_add(SELECTED.len()) > limit {
1920 return Err(IrError::OutputLimitExceeded { limit });
1921 }
1922 out.insert_str(option.insert_at, SELECTED);
1923 }
1924 Ok(())
1925}
1926
1927fn strip_and_collapse_ascii_whitespace(text: &str) -> String {
1930 let mut out = String::with_capacity(text.len());
1931 for word in text.split_ascii_whitespace() {
1932 if !out.is_empty() {
1933 out.push(' ');
1934 }
1935 out.push_str(word);
1936 }
1937 out
1938}
1939
1940pub(crate) fn read_u16(data: &[u8], pos: usize) -> Result<u16, IrError> {
1946 if pos + 2 > data.len() {
1947 return Err(IrError::BufferTooShort {
1948 expected: pos + 2,
1949 actual: data.len(),
1950 });
1951 }
1952 Ok(u16::from_le_bytes([data[pos], data[pos + 1]]))
1953}
1954
1955pub(crate) fn read_u32(data: &[u8], pos: usize) -> Result<u32, IrError> {
1957 if pos + 4 > data.len() {
1958 return Err(IrError::BufferTooShort {
1959 expected: pos + 4,
1960 actual: data.len(),
1961 });
1962 }
1963 Ok(u32::from_le_bytes([
1964 data[pos],
1965 data[pos + 1],
1966 data[pos + 2],
1967 data[pos + 3],
1968 ]))
1969}
1970
1971pub(crate) fn read_tag_with_attrs<'a>(
1976 ops: &[u8],
1977 pos: usize,
1978 strings: &'a crate::parser::StringTable,
1979) -> Result<(u32, Vec<(&'a str, &'a str)>, usize), IrError> {
1980 let tag_str_idx = read_u32(ops, pos)?;
1981 let attr_count = read_u16(ops, pos + 4)? as usize;
1982 let mut cursor = pos + 6; let mut attrs = Vec::with_capacity(attr_count.min(ops.len().saturating_sub(cursor) / 8));
1988 for _ in 0..attr_count {
1989 let key_idx = read_u32(ops, cursor)?;
1990 let val_idx = read_u32(ops, cursor + 4)?;
1991 let key = strings.get(key_idx)?;
1992 let val = strings.get(val_idx)?;
1993 attrs.push((key, val));
1994 cursor += 8;
1995 }
1996
1997 Ok((tag_str_idx, attrs, cursor))
1998}
1999
2000const MAX_ESCAPE_EXPANSION: usize = 6;
2007
2008fn push_checked(out: &mut String, s: &str, limit: usize) -> Result<(), IrError> {
2015 if out.len().saturating_add(s.len()) > limit {
2016 return Err(IrError::OutputLimitExceeded { limit });
2017 }
2018 out.push_str(s);
2019 Ok(())
2020}
2021
2022fn push_u16(out: &mut String, value: u16, limit: usize) -> Result<(), IrError> {
2028 let mut buf = [0u8; 5];
2030 let mut remaining = value;
2031 let mut start = buf.len();
2032 loop {
2033 start -= 1;
2034 buf[start] = b'0' + (remaining % 10) as u8;
2035 remaining /= 10;
2036 if remaining == 0 {
2037 break;
2038 }
2039 }
2040 match core::str::from_utf8(&buf[start..]) {
2043 Ok(digits) => push_checked(out, digits, limit),
2044 Err(e) => Err(IrError::InvalidUtf8(e.to_string())),
2045 }
2046}
2047
2048fn push_escaped_html(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
2050 if out
2052 .len()
2053 .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
2054 <= limit
2055 {
2056 for ch in text.chars() {
2057 match ch {
2058 '&' => out.push_str("&"),
2059 '<' => out.push_str("<"),
2060 '>' => out.push_str(">"),
2061 _ => out.push(ch),
2062 }
2063 }
2064 return Ok(());
2065 }
2066 for ch in text.chars() {
2067 match ch {
2068 '&' => push_checked(out, "&", limit)?,
2069 '<' => push_checked(out, "<", limit)?,
2070 '>' => push_checked(out, ">", limit)?,
2071 _ => {
2072 if out.len() >= limit {
2073 return Err(IrError::OutputLimitExceeded { limit });
2074 }
2075 out.push(ch);
2076 }
2077 }
2078 }
2079 Ok(())
2080}
2081
2082fn push_escaped_attr(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
2084 if out
2085 .len()
2086 .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
2087 <= limit
2088 {
2089 for ch in text.chars() {
2090 match ch {
2091 '&' => out.push_str("&"),
2092 '"' => out.push_str("""),
2093 '<' => out.push_str("<"),
2094 '>' => out.push_str(">"),
2095 _ => out.push(ch),
2096 }
2097 }
2098 return Ok(());
2099 }
2100 for ch in text.chars() {
2101 match ch {
2102 '&' => push_checked(out, "&", limit)?,
2103 '"' => push_checked(out, """, limit)?,
2104 '<' => push_checked(out, "<", limit)?,
2105 '>' => push_checked(out, ">", limit)?,
2106 _ => {
2107 if out.len() >= limit {
2108 return Err(IrError::OutputLimitExceeded { limit });
2109 }
2110 out.push(ch);
2111 }
2112 }
2113 }
2114 Ok(())
2115}
2116
2117#[cfg(test)]
2122mod tests {
2123 use super::*;
2124 use crate::parser::test_helpers::{
2125 build_minimal_ir, encode_close_tag, encode_list, encode_open_tag, encode_show_if,
2126 encode_text, encode_void_tag,
2127 };
2128 use crate::slot::{SlotData, SlotValue};
2129
2130 fn encode_dyn_text(slot_id: u16, marker_id: u16) -> Vec<u8> {
2134 let mut buf = Vec::new();
2135 buf.push(0x05); buf.extend_from_slice(&slot_id.to_le_bytes());
2137 buf.extend_from_slice(&marker_id.to_le_bytes());
2138 buf
2139 }
2140
2141 fn encode_island_start(island_id: u16) -> Vec<u8> {
2143 let mut buf = vec![0x0B];
2144 buf.extend_from_slice(&island_id.to_le_bytes());
2145 buf
2146 }
2147
2148 fn encode_island_end(island_id: u16) -> Vec<u8> {
2150 let mut buf = vec![0x0C];
2151 buf.extend_from_slice(&island_id.to_le_bytes());
2152 buf
2153 }
2154
2155 fn walk_static(strings: &[&str], opcodes: &[u8]) -> String {
2157 let data = build_minimal_ir(strings, &[], opcodes, &[]);
2158 let module = IrModule::parse(&data).unwrap();
2159 let slots = SlotData::new(0);
2160 walk_to_html(&module, &slots).unwrap()
2161 }
2162
2163 fn walk_with_slots(
2165 strings: &[&str],
2166 slot_decls: &[(u16, u32, u8, u8, &[u8])],
2167 opcodes: &[u8],
2168 slots: &SlotData,
2169 ) -> String {
2170 let data = build_minimal_ir(strings, slot_decls, opcodes, &[]);
2171 let module = IrModule::parse(&data).unwrap();
2172 walk_to_html(&module, slots).unwrap()
2173 }
2174
2175 fn walk_with_islands(
2177 strings: &[&str],
2178 slot_decls: &[(u16, u32, u8, u8, &[u8])],
2179 island_decls: &[(u16, u8, u8, u32, u32, &[u16])],
2180 opcodes: &[u8],
2181 slots: &SlotData,
2182 ) -> String {
2183 let data = build_minimal_ir(strings, slot_decls, opcodes, island_decls);
2184 let module = IrModule::parse(&data).unwrap();
2185 walk_to_html(&module, slots).unwrap()
2186 }
2187
2188 fn encode_dyn_attr(attr_str_idx: u32, slot_id: u16) -> Vec<u8> {
2190 let mut bytes = vec![0x06]; bytes.extend_from_slice(&attr_str_idx.to_le_bytes());
2192 bytes.extend_from_slice(&slot_id.to_le_bytes());
2193 bytes
2194 }
2195
2196 #[test]
2199 fn walk_static_div() {
2200 let mut opcodes = Vec::new();
2203 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
2204 opcodes.extend_from_slice(&encode_text(3));
2205 opcodes.extend_from_slice(&encode_close_tag(0));
2206
2207 let html = walk_static(&["div", "class", "container", "Hello"], &opcodes);
2208 assert_eq!(html, r#"<div class="container">Hello</div>"#);
2209 }
2210
2211 #[test]
2214 fn walk_void_tag() {
2215 let opcodes = encode_void_tag(0, &[(1, 2)]);
2218
2219 let html = walk_static(&["input", "type", "email"], &opcodes);
2220 assert_eq!(html, r#"<input type="email">"#);
2221 }
2222
2223 #[test]
2226 fn walk_nested_elements() {
2227 let mut opcodes = Vec::new();
2230 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2231 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
2232 opcodes.extend_from_slice(&encode_text(2));
2233 opcodes.extend_from_slice(&encode_close_tag(1));
2234 opcodes.extend_from_slice(&encode_close_tag(0));
2235
2236 let html = walk_static(&["div", "span", "Hi"], &opcodes);
2237 assert_eq!(html, "<div><span>Hi</span></div>");
2238 }
2239
2240 #[test]
2243 fn walk_text_escaping() {
2244 let opcodes = encode_text(0);
2246
2247 let html = walk_static(&["<script>alert('xss')</script>"], &opcodes);
2248 assert_eq!(html, "<script>alert('xss')</script>");
2249 }
2250
2251 #[test]
2254 fn walk_attr_escaping() {
2255 let opcodes = encode_open_tag(0, &[(1, 2)]);
2258
2259 let html = walk_static(&["div", "title", "a\"b"], &opcodes);
2260 assert_eq!(html, r#"<div title="a"b">"#);
2261 }
2262
2263 #[test]
2266 fn walk_empty_element() {
2267 let mut opcodes = Vec::new();
2269 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2270 opcodes.extend_from_slice(&encode_close_tag(0));
2271
2272 let html = walk_static(&["div"], &opcodes);
2273 assert_eq!(html, "<div></div>");
2274 }
2275
2276 #[test]
2279 fn walk_dyn_text() {
2280 let opcodes = encode_dyn_text(0, 0);
2284
2285 let mut slots = SlotData::new(1);
2286 slots.set(0, SlotValue::Text("World".to_string()));
2287
2288 let html = walk_with_slots(&["greeting"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2289 assert_eq!(html, "<!--f:t0-->World<!--/f:t0-->");
2290 }
2291
2292 #[test]
2293 fn walk_dyn_text_null_emits_zwsp() {
2294 let opcodes = encode_dyn_text(0, 0);
2297 let slots = SlotData::new(1); let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2300 assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
2301 }
2302
2303 #[test]
2304 fn walk_dyn_text_empty_string_emits_zwsp() {
2305 let opcodes = encode_dyn_text(0, 0);
2307 let mut slots = SlotData::new(1);
2308 slots.set(0, SlotValue::Text(String::new()));
2309
2310 let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2311 assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
2312 }
2313
2314 #[test]
2317 fn walk_dyn_text_escaping() {
2318 let opcodes = encode_dyn_text(0, 1);
2320
2321 let mut slots = SlotData::new(1);
2322 slots.set(0, SlotValue::Text("<b>bold</b>".to_string()));
2323
2324 let html = walk_with_slots(&["content"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2325 assert_eq!(html, "<!--f:t1--><b>bold</b><!--/f:t1-->");
2326 }
2327
2328 #[test]
2331 fn walk_island_markers() {
2332 let mut opcodes = Vec::new();
2336 opcodes.extend_from_slice(&encode_island_start(0));
2337 opcodes.extend_from_slice(&encode_text(0));
2338 opcodes.extend_from_slice(&encode_island_end(0));
2339
2340 let slots = SlotData::new(0);
2341 let html = walk_with_islands(
2342 &["Hello"],
2343 &[],
2344 &[(0, 0x01, 0x01, 0, 0, &[])],
2345 &opcodes,
2346 &slots,
2347 );
2348 assert_eq!(html, "<!--f:i0-->Hello<!--/f:i0-->");
2349 }
2350
2351 #[test]
2356 fn walk_boolean_attr() {
2357 let opcodes = encode_void_tag(0, &[(1, 2)]);
2360
2361 let html = walk_static(&["input", "disabled", ""], &opcodes);
2362 assert_eq!(html, "<input disabled>");
2363 }
2364
2365 fn walk_dyn_attr_with(value: SlotValue) -> String {
2369 let mut opcodes = encode_void_tag(0, &[]);
2370 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
2371 let mut slots = SlotData::new(1);
2372 slots.set(0, value);
2373 walk_with_slots(
2374 &["input", "disabled"],
2375 &[(0, 1, 0x02, 0x00, &[])],
2376 &opcodes,
2377 &slots,
2378 )
2379 }
2380
2381 #[test]
2382 fn dyn_attr_bool_matches_the_client_renderer() {
2383 assert_eq!(
2391 walk_dyn_attr_with(SlotValue::Bool(true)),
2392 "<input disabled>",
2393 "true must render the bare attribute"
2394 );
2395 assert_eq!(
2396 walk_dyn_attr_with(SlotValue::Bool(false)),
2397 "<input>",
2398 "false must omit the attribute, not write disabled=\"false\""
2399 );
2400 assert_eq!(
2401 walk_dyn_attr_with(SlotValue::Null),
2402 "<input>",
2403 "null must omit the attribute"
2404 );
2405 }
2406
2407 #[test]
2408 fn dyn_attr_empty_string_is_a_value_not_an_absence() {
2409 assert_eq!(
2413 walk_dyn_attr_with(SlotValue::Text(String::new())),
2414 "<input disabled=\"\">"
2415 );
2416 assert_eq!(
2417 walk_dyn_attr_with(SlotValue::Text("yes".into())),
2418 "<input disabled=\"yes\">"
2419 );
2420 assert_eq!(
2422 walk_dyn_attr_with(SlotValue::Number(0.0)),
2423 "<input disabled=\"0\">"
2424 );
2425 }
2426
2427 #[test]
2430 fn walk_multiple_attrs() {
2431 let mut opcodes = Vec::new();
2434 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2), (3, 4), (5, 6)]));
2435 opcodes.extend_from_slice(&encode_close_tag(0));
2436
2437 let html = walk_static(
2438 &["div", "id", "main", "class", "container", "data-x", "42"],
2439 &opcodes,
2440 );
2441 assert_eq!(
2442 html,
2443 r#"<div id="main" class="container" data-x="42"></div>"#
2444 );
2445 }
2446
2447 #[test]
2450 fn walk_show_if_true_branch() {
2451 let then_ops = encode_text(1); let else_ops = encode_text(2); let opcodes = encode_show_if(0, &then_ops, &else_ops);
2457
2458 let mut slots = SlotData::new(1);
2459 slots.set(0, SlotValue::Bool(true));
2460
2461 let html = walk_with_slots(
2462 &["visible", "Yes", "No"],
2463 &[(0, 0, 0x02, 0x00, &[])], &opcodes,
2465 &slots,
2466 );
2467 assert!(html.contains("Yes"), "should contain then-branch text");
2468 assert!(!html.contains("No"), "should NOT contain else-branch text");
2469 assert!(html.contains("<!--f:s0-->"), "should have opening marker");
2470 assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
2471 }
2472
2473 #[test]
2476 fn walk_show_if_false_branch() {
2477 let then_ops = encode_text(1);
2480 let else_ops = encode_text(2);
2481 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2482
2483 let mut slots = SlotData::new(1);
2484 slots.set(0, SlotValue::Bool(false));
2485
2486 let html = walk_with_slots(
2487 &["visible", "Yes", "No"],
2488 &[(0, 0, 0x02, 0x00, &[])],
2489 &opcodes,
2490 &slots,
2491 );
2492 assert!(html.contains("No"), "should contain else-branch text");
2493 assert!(!html.contains("Yes"), "should NOT contain then-branch text");
2494 assert!(html.contains("<!--f:s0-->"), "should have opening marker");
2495 assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
2496 }
2497
2498 #[test]
2501 fn walk_show_if_truthy_text() {
2502 let then_ops = encode_text(1);
2504 let else_ops = encode_text(2);
2505 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2506
2507 let mut slots = SlotData::new(1);
2508 slots.set(0, SlotValue::Text("hello".to_string()));
2509
2510 let html = walk_with_slots(
2511 &["greeting", "Shown", "Hidden"],
2512 &[(0, 0, 0x01, 0x00, &[])], &opcodes,
2514 &slots,
2515 );
2516 assert!(
2517 html.contains("Shown"),
2518 "truthy text should render then branch"
2519 );
2520 assert!(
2521 !html.contains("Hidden"),
2522 "truthy text should NOT render else branch"
2523 );
2524 }
2525
2526 #[test]
2529 fn walk_show_if_falsy_null() {
2530 let then_ops = encode_text(1);
2532 let else_ops = encode_text(2);
2533 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2534
2535 let slots = SlotData::new(1); let html = walk_with_slots(
2538 &["maybe", "Present", "Missing"],
2539 &[(0, 0, 0x01, 0x00, &[])],
2540 &opcodes,
2541 &slots,
2542 );
2543 assert!(html.contains("Missing"), "null should render else branch");
2544 assert!(
2545 !html.contains("Present"),
2546 "null should NOT render then branch"
2547 );
2548 }
2549
2550 #[test]
2553 fn walk_show_if_no_else() {
2554 let then_ops = encode_text(1);
2556 let else_ops: Vec<u8> = vec![]; let opcodes = encode_show_if(0, &then_ops, &else_ops);
2558
2559 let slots = SlotData::new(1); let html = walk_with_slots(
2562 &["flag", "Content"],
2563 &[(0, 0, 0x02, 0x00, &[])],
2564 &opcodes,
2565 &slots,
2566 );
2567 assert_eq!(
2568 html, "<!--f:s0--><!--/f:s0-->",
2569 "empty else should produce only markers"
2570 );
2571 }
2572
2573 #[test]
2576 fn walk_show_if_nested() {
2577 let inner_then = encode_text(4);
2581 let inner_else = encode_text(5);
2582 let inner_show_if = encode_show_if(1, &inner_then, &inner_else);
2583
2584 let outer_then = inner_show_if; let outer_else = encode_text(3);
2586 let opcodes = encode_show_if(0, &outer_then, &outer_else);
2587
2588 let mut slots = SlotData::new(2);
2589 slots.set(0, SlotValue::Bool(true)); slots.set(1, SlotValue::Bool(false)); let html = walk_with_slots(
2593 &[
2594 "outer",
2595 "inner",
2596 "outer-yes",
2597 "outer-no",
2598 "inner-yes",
2599 "inner-no",
2600 ],
2601 &[(0, 0, 0x02, 0x00, &[]), (1, 1, 0x02, 0x00, &[])],
2602 &opcodes,
2603 &slots,
2604 );
2605 assert!(
2607 html.contains("inner-no"),
2608 "nested: inner false should render inner else"
2609 );
2610 assert!(
2611 !html.contains("inner-yes"),
2612 "nested: inner true branch should not render"
2613 );
2614 assert!(
2615 !html.contains("outer-no"),
2616 "nested: outer else should not render"
2617 );
2618 assert!(
2620 html.contains("<!--f:s0-->"),
2621 "should have outer opening marker"
2622 );
2623 assert!(
2624 html.contains("<!--/f:s0-->"),
2625 "should have outer closing marker"
2626 );
2627 assert!(
2628 html.contains("<!--f:s1-->"),
2629 "should have inner opening marker"
2630 );
2631 assert!(
2632 html.contains("<!--/f:s1-->"),
2633 "should have inner closing marker"
2634 );
2635 }
2636
2637 #[test]
2640 fn walk_show_if_with_elements() {
2641 let mut then_ops = Vec::new();
2644 then_ops.extend_from_slice(&encode_open_tag(1, &[]));
2645 then_ops.extend_from_slice(&encode_text(2));
2646 then_ops.extend_from_slice(&encode_close_tag(1));
2647
2648 let else_ops = encode_text(3);
2649 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2650
2651 let mut slots = SlotData::new(1);
2652 slots.set(0, SlotValue::Bool(true));
2653
2654 let html = walk_with_slots(
2655 &["flag", "span", "Hello", "Bye"],
2656 &[(0, 0, 0x02, 0x00, &[])],
2657 &opcodes,
2658 &slots,
2659 );
2660 assert_eq!(
2661 html, "<!--f:s0--><span>Hello</span><!--/f:s0-->",
2662 "then branch should produce full HTML element"
2663 );
2664 }
2665
2666 #[test]
2669 fn walk_list_renders_items() {
2670 let body = encode_dyn_text(1, 0);
2674 let opcodes = encode_list(0, 1, &body);
2675
2676 let mut slots = SlotData::new(2);
2677 slots.set(
2678 0,
2679 SlotValue::array(vec![
2680 SlotValue::Text("Alice".to_string()),
2681 SlotValue::Text("Bob".to_string()),
2682 ]),
2683 );
2684
2685 let html = walk_with_slots(
2686 &["items", "item"],
2687 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
2689 &slots,
2690 );
2691 assert!(html.contains("Alice"), "should contain first item");
2692 assert!(html.contains("Bob"), "should contain second item");
2693 assert!(
2694 html.starts_with("<!--f:l0-->"),
2695 "should start with list open marker"
2696 );
2697 assert!(
2698 html.ends_with("<!--/f:l0-->"),
2699 "should end with list close marker"
2700 );
2701 }
2702
2703 #[test]
2706 fn walk_list_empty_array() {
2707 let body = encode_dyn_text(1, 0);
2710 let opcodes = encode_list(0, 1, &body);
2711
2712 let mut slots = SlotData::new(2);
2713 slots.set(0, SlotValue::array(vec![]));
2714
2715 let html = walk_with_slots(
2716 &["items", "item"],
2717 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2718 &opcodes,
2719 &slots,
2720 );
2721 assert_eq!(
2722 html, "<!--f:l0--><!--/f:l0-->",
2723 "empty array should produce only markers"
2724 );
2725 }
2726
2727 #[test]
2730 fn walk_list_with_elements() {
2731 let mut body = Vec::new();
2735 body.extend_from_slice(&encode_open_tag(2, &[]));
2736 body.extend_from_slice(&encode_dyn_text(1, 0));
2737 body.extend_from_slice(&encode_close_tag(2));
2738
2739 let opcodes = encode_list(0, 1, &body);
2740
2741 let mut slots = SlotData::new(2);
2742 slots.set(
2743 0,
2744 SlotValue::array(vec![
2745 SlotValue::Text("A".to_string()),
2746 SlotValue::Text("B".to_string()),
2747 ]),
2748 );
2749
2750 let html = walk_with_slots(
2751 &["items", "item", "li"],
2752 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2753 &opcodes,
2754 &slots,
2755 );
2756 assert!(
2757 html.contains("<li><!--f:t0-->A<!--/f:t0--></li><li><!--f:t0-->B<!--/f:t0--></li>"),
2758 "should render li elements with content, got: {html}"
2759 );
2760 }
2761
2762 #[test]
2765 fn walk_list_null_slot() {
2766 let body = encode_dyn_text(1, 0);
2769 let opcodes = encode_list(0, 1, &body);
2770
2771 let slots = SlotData::new(2); let html = walk_with_slots(
2774 &["items", "item"],
2775 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2776 &opcodes,
2777 &slots,
2778 );
2779 assert_eq!(
2780 html, "<!--f:l0--><!--/f:l0-->",
2781 "null slot should produce only markers"
2782 );
2783 }
2784
2785 #[test]
2788 fn walk_list_nested() {
2789 let inner_body = encode_dyn_text(2, 0);
2794 let inner_list = encode_list(1, 2, &inner_body);
2795 let opcodes = encode_list(0, 1, &inner_list);
2796
2797 let mut slots = SlotData::new(3);
2798 slots.set(
2799 0,
2800 SlotValue::array(vec![
2801 SlotValue::array(vec![
2802 SlotValue::Text("a".to_string()),
2803 SlotValue::Text("b".to_string()),
2804 ]),
2805 SlotValue::array(vec![SlotValue::Text("c".to_string())]),
2806 ]),
2807 );
2808
2809 let html = walk_with_slots(
2810 &["outer", "inner", "item"],
2811 &[
2812 (0, 0, 0x04, 0x00, &[]),
2813 (1, 1, 0x04, 0x00, &[]),
2814 (2, 2, 0x01, 0x00, &[]),
2815 ],
2816 &opcodes,
2817 &slots,
2818 );
2819 assert!(html.contains("a"), "should contain nested item 'a'");
2821 assert!(html.contains("b"), "should contain nested item 'b'");
2822 assert!(html.contains("c"), "should contain nested item 'c'");
2823 assert!(
2825 html.starts_with("<!--f:l0-->"),
2826 "should have outer list open marker"
2827 );
2828 assert!(
2829 html.ends_with("<!--/f:l0-->"),
2830 "should have outer list close marker"
2831 );
2832 assert!(
2834 html.contains("<!--f:l1-->"),
2835 "should have inner list open marker"
2836 );
2837 assert!(
2838 html.contains("<!--/f:l1-->"),
2839 "should have inner list close marker"
2840 );
2841 }
2842
2843 #[test]
2846 fn walk_list_depth_exceeded() {
2847 let innermost_body = encode_dyn_text(5, 0);
2851 let level5 = encode_list(4, 5, &innermost_body);
2853 let level4 = encode_list(3, 4, &level5);
2854 let level3 = encode_list(2, 3, &level4);
2855 let level2 = encode_list(1, 2, &level3);
2856 let level1 = encode_list(0, 1, &level2);
2857
2858 let mut slots = SlotData::new(6);
2859 slots.set(
2861 0,
2862 SlotValue::array(vec![SlotValue::array(vec![SlotValue::array(vec![
2863 SlotValue::array(vec![SlotValue::array(vec![SlotValue::Text(
2864 "deep".to_string(),
2865 )])]),
2866 ])])]),
2867 );
2868 let data = build_minimal_ir(
2871 &["s0", "s1", "s2", "s3", "s4", "s5"],
2872 &[
2873 (0, 0, 0x04, 0x00, &[]),
2874 (1, 1, 0x04, 0x00, &[]),
2875 (2, 2, 0x04, 0x00, &[]),
2876 (3, 3, 0x04, 0x00, &[]),
2877 (4, 4, 0x04, 0x00, &[]),
2878 (5, 5, 0x01, 0x00, &[]),
2879 ],
2880 &level1,
2881 &[],
2882 );
2883 let module = IrModule::parse(&data).unwrap();
2884 let result = walk_to_html(&module, &slots);
2885 assert!(result.is_err(), "should fail with depth exceeded");
2886 match result.unwrap_err() {
2887 IrError::ListDepthExceeded { max: 4 } => {}
2888 other => panic!("expected ListDepthExceeded {{ max: 4 }}, got {other:?}"),
2889 }
2890 }
2891
2892 #[test]
2916 fn roundtrip_page_structure() {
2917 let mut opcodes = Vec::new();
2925 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(
2939 &[
2940 "html", "lang", "en", "head", "meta", "charset", "utf-8", "title", "Test", "body",
2941 "div", "id", "app",
2942 ],
2943 &[],
2944 &opcodes,
2945 &[],
2946 );
2947 let module = IrModule::parse(&ir).unwrap();
2948 let slots = SlotData::new(0);
2949 let html = walk_to_html(&module, &slots).unwrap();
2950 assert_eq!(
2951 html,
2952 r#"<html lang="en"><head><meta charset="utf-8"><title>Test</title></head><body><div id="app"></div></body></html>"#
2953 );
2954 }
2955
2956 #[test]
2959 fn roundtrip_dynamic_greeting() {
2960 let mut opcodes = Vec::new();
2966 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(
2971 &["div", "class", "greeting", "content"],
2972 &[(0, 3, 0x01, 0x00, &[])], &opcodes,
2974 &[],
2975 );
2976 let module = IrModule::parse(&ir).unwrap();
2977 let mut slots = SlotData::new(1);
2978 slots.set(0, SlotValue::Text("Hello, World".into()));
2979 let html = walk_to_html(&module, &slots).unwrap();
2980 assert_eq!(
2981 html,
2982 r#"<div class="greeting"><!--f:t0-->Hello, World<!--/f:t0--></div>"#
2983 );
2984 }
2985
2986 #[test]
2989 fn roundtrip_conditional_content() {
2990 let mut then_ops = Vec::new();
2997 then_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
2998 then_ops.extend_from_slice(&encode_text(4));
2999 then_ops.extend_from_slice(&encode_close_tag(1));
3000
3001 let mut else_ops = Vec::new();
3002 else_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
3003 else_ops.extend_from_slice(&encode_text(5));
3004 else_ops.extend_from_slice(&encode_close_tag(1));
3005
3006 let opcodes = encode_show_if(0, &then_ops, &else_ops);
3007
3008 let ir = build_minimal_ir(
3009 &["auth", "span", "class", "badge", "Logged In", "Guest"],
3010 &[(0, 0, 0x02, 0x00, &[])], &opcodes,
3012 &[],
3013 );
3014
3015 {
3017 let module = IrModule::parse(&ir).unwrap();
3018 let mut slots = SlotData::new(1);
3019 slots.set(0, SlotValue::Bool(true));
3020 let html = walk_to_html(&module, &slots).unwrap();
3021 assert_eq!(
3022 html,
3023 r#"<!--f:s0--><span class="badge">Logged In</span><!--/f:s0-->"#
3024 );
3025 }
3026
3027 {
3029 let module = IrModule::parse(&ir).unwrap();
3030 let mut slots = SlotData::new(1);
3031 slots.set(0, SlotValue::Bool(false));
3032 let html = walk_to_html(&module, &slots).unwrap();
3033 assert_eq!(
3034 html,
3035 r#"<!--f:s0--><span class="badge">Guest</span><!--/f:s0-->"#
3036 );
3037 }
3038 }
3039
3040 #[test]
3043 fn roundtrip_list_rendering() {
3044 let mut body = Vec::new();
3050 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);
3055
3056 let ir = build_minimal_ir(
3057 &["items", "item", "li"],
3058 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
3060 &[],
3061 );
3062 let module = IrModule::parse(&ir).unwrap();
3063 let mut slots = SlotData::new(2);
3064 slots.set(
3065 0,
3066 SlotValue::array(vec![
3067 SlotValue::Text("Alice".into()),
3068 SlotValue::Text("Bob".into()),
3069 SlotValue::Text("Charlie".into()),
3070 ]),
3071 );
3072 let html = walk_to_html(&module, &slots).unwrap();
3073 assert_eq!(
3074 html,
3075 "<!--f:l0-->\
3076 <li><!--f:t0-->Alice<!--/f:t0--></li>\
3077 <li><!--f:t0-->Bob<!--/f:t0--></li>\
3078 <li><!--f:t0-->Charlie<!--/f:t0--></li>\
3079 <!--/f:l0-->"
3080 );
3081 }
3082
3083 #[test]
3088 fn roundtrip_nested_structure() {
3089 let mut then_ops = Vec::new();
3110 then_ops.extend_from_slice(&encode_open_tag(4, &[]));
3111 then_ops.extend_from_slice(&encode_text(5));
3112 then_ops.extend_from_slice(&encode_close_tag(4));
3113
3114 let show_if_ops = encode_show_if(0, &then_ops, &[]);
3115
3116 let mut list_body = Vec::new();
3118 list_body.extend_from_slice(&encode_open_tag(9, &[]));
3119 list_body.extend_from_slice(&encode_dyn_text(2, 0));
3120 list_body.extend_from_slice(&encode_close_tag(9));
3121
3122 let list_ops = encode_list(1, 2, &list_body);
3123
3124 let mut opcodes = Vec::new();
3126 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(
3134 &[
3135 "nav", "class", "main-nav", "auth", "span", "Welcome", "items", "item", "ul", "li",
3136 ],
3137 &[
3138 (0, 3, 0x02, 0x00, &[]), (1, 6, 0x04, 0x00, &[]), (2, 7, 0x01, 0x00, &[]), ],
3142 &opcodes,
3143 &[],
3144 );
3145 let module = IrModule::parse(&ir).unwrap();
3146 let mut slots = SlotData::new(3);
3147 slots.set(0, SlotValue::Bool(true));
3148 slots.set(
3149 1,
3150 SlotValue::array(vec![
3151 SlotValue::Text("Home".into()),
3152 SlotValue::Text("About".into()),
3153 ]),
3154 );
3155
3156 let html = walk_to_html(&module, &slots).unwrap();
3157 assert_eq!(
3158 html,
3159 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>"#
3160 );
3161 }
3162
3163 #[test]
3170 fn walk_island_attrs_on_open_tag() {
3171 let mut opcodes = Vec::new();
3175 opcodes.extend_from_slice(&encode_island_start(0));
3176 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3177 opcodes.extend_from_slice(&encode_text(1));
3178 opcodes.extend_from_slice(&encode_close_tag(0));
3179 opcodes.extend_from_slice(&encode_island_end(0));
3180
3181 let slots = SlotData::new(0);
3182 let html = walk_with_islands(
3183 &["div", "Hello", "AuthForm"],
3184 &[],
3185 &[(0, 0x01, 0x01, 2, 0, &[])],
3187 &opcodes,
3188 &slots,
3189 );
3190 assert_eq!(
3191 html,
3192 r#"<!--f:i0--><div data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load">Hello</div><!--/f:i0-->"#
3193 );
3194 }
3195
3196 #[test]
3199 fn walk_island_attrs_with_existing_attrs() {
3200 let mut opcodes = Vec::new();
3203 opcodes.extend_from_slice(&encode_island_start(0));
3204 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
3205 opcodes.extend_from_slice(&encode_close_tag(0));
3206 opcodes.extend_from_slice(&encode_island_end(0));
3207
3208 let slots = SlotData::new(0);
3209 let html = walk_with_islands(
3210 &["div", "class", "form", "AuthForm"],
3211 &[],
3212 &[(0, 0x01, 0x01, 3, 0, &[])],
3213 &opcodes,
3214 &slots,
3215 );
3216 assert_eq!(
3217 html,
3218 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-->"#
3219 );
3220 }
3221
3222 #[test]
3225 fn walk_island_attrs_on_void_tag() {
3226 let mut opcodes = Vec::new();
3229 opcodes.extend_from_slice(&encode_island_start(0));
3230 opcodes.extend_from_slice(&encode_void_tag(0, &[]));
3231 opcodes.extend_from_slice(&encode_island_end(0));
3232
3233 let slots = SlotData::new(0);
3234 let html = walk_with_islands(
3235 &["input", "Widget"],
3236 &[],
3237 &[(0, 0x01, 0x01, 1, 0, &[])],
3238 &opcodes,
3239 &slots,
3240 );
3241 assert_eq!(
3242 html,
3243 r#"<!--f:i0--><input data-forma-island="0" data-forma-component="Widget" data-forma-status="pending" data-forma-hydrate="load"><!--/f:i0-->"#
3244 );
3245 }
3246
3247 #[test]
3250 fn walk_inline_props_attribute() {
3251 let mut opcodes = Vec::new();
3256 opcodes.extend_from_slice(&encode_island_start(0));
3257 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3258 opcodes.extend_from_slice(&encode_close_tag(0));
3259 opcodes.extend_from_slice(&encode_island_end(0));
3260
3261 let mut slots = SlotData::new(2);
3262 slots.set(0, SlotValue::Text("Hello".to_string()));
3263 slots.set(1, SlotValue::Number(42.0));
3264
3265 let html = walk_with_islands(
3266 &["div", "MyComponent", "title", "count"],
3267 &[
3268 (0, 2, 0x01, 0x00, &[]), (1, 3, 0x03, 0x00, &[]), ],
3271 &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
3273 &opcodes,
3274 &slots,
3275 );
3276 assert!(
3278 html.contains("data-forma-props="),
3279 "should contain data-forma-props, got: {html}"
3280 );
3281 assert!(
3282 html.contains(r#"data-forma-island="0""#),
3283 "should contain island id, got: {html}"
3284 );
3285 assert!(
3286 html.contains(r#"data-forma-component="MyComponent""#),
3287 "should contain component name, got: {html}"
3288 );
3289 let marker = "data-forma-props=\"";
3295 let props_start = html.find(marker).unwrap() + marker.len();
3296 let props_end = html[props_start..].find('"').unwrap() + props_start;
3297 let raw_attr = &html[props_start..props_end];
3298 let decoded = raw_attr
3299 .replace(""", "\"")
3300 .replace("&", "&")
3301 .replace("<", "<")
3302 .replace(">", ">");
3303 let props_json: serde_json::Value = serde_json::from_str(&decoded).unwrap();
3304 assert_eq!(props_json["title"], "Hello");
3305 assert_eq!(props_json["count"], 42);
3306 }
3307
3308 #[test]
3321 fn walk_island_no_entry_in_table() {
3322 let mut opcodes = Vec::new();
3327 opcodes.extend_from_slice(&encode_island_start(5));
3328 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3329 opcodes.extend_from_slice(&encode_text(1));
3330 opcodes.extend_from_slice(&encode_close_tag(0));
3331 opcodes.extend_from_slice(&encode_island_end(5));
3332
3333 let data = build_minimal_ir(&["div", "Hello"], &[], &opcodes, &[]);
3334 assert_eq!(
3335 IrModule::parse(&data).unwrap_err(),
3336 IrError::UnknownIslandId(5)
3337 );
3338 }
3339
3340 #[test]
3341 fn walk_nested_island_start_before_a_root_element_is_an_error() {
3342 let mut opcodes = Vec::new();
3346 opcodes.extend_from_slice(&encode_island_start(0));
3347 opcodes.extend_from_slice(&encode_island_start(1));
3348 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3349 opcodes.extend_from_slice(&encode_close_tag(0));
3350 opcodes.extend_from_slice(&encode_island_end(1));
3351 opcodes.extend_from_slice(&encode_island_end(0));
3352
3353 let slots = SlotData::new(0);
3354 let data = build_minimal_ir(
3355 &["div", "Outer", "Inner"],
3356 &[],
3357 &opcodes,
3358 &[(0, 0x01, 0x01, 1, 0, &[]), (1, 0x01, 0x01, 2, 3, &[])],
3359 );
3360 let module = IrModule::parse(&data).unwrap();
3361 assert_eq!(
3362 walk_to_html(&module, &slots).unwrap_err(),
3363 IrError::NestedIslandRoot { outer: 0, inner: 1 }
3364 );
3365 }
3366
3367 #[test]
3368 fn walk_island_nested_inside_another_islands_root_is_fine() {
3369 let mut opcodes = Vec::new();
3371 opcodes.extend_from_slice(&encode_island_start(0));
3372 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3373 opcodes.extend_from_slice(&encode_island_start(1));
3374 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3375 opcodes.extend_from_slice(&encode_close_tag(0));
3376 opcodes.extend_from_slice(&encode_island_end(1));
3377 opcodes.extend_from_slice(&encode_close_tag(0));
3378 opcodes.extend_from_slice(&encode_island_end(0));
3379
3380 let slots = SlotData::new(0);
3381 let inner_offset = (encode_island_start(0).len() + encode_open_tag(0, &[]).len()) as u32;
3382 let html = walk_with_islands(
3383 &["div", "Outer", "Inner"],
3384 &[],
3385 &[
3386 (0, 0x01, 0x01, 1, 0, &[]),
3387 (1, 0x01, 0x01, 2, inner_offset, &[]),
3388 ],
3389 &opcodes,
3390 &slots,
3391 );
3392 assert!(html.contains(r#"data-forma-component="Outer""#));
3393 assert!(html.contains(r#"data-forma-component="Inner""#));
3394 }
3395
3396 #[test]
3399 fn walk_inline_props_empty_slots() {
3400 let mut opcodes = Vec::new();
3403 opcodes.extend_from_slice(&encode_island_start(0));
3404 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3405 opcodes.extend_from_slice(&encode_close_tag(0));
3406 opcodes.extend_from_slice(&encode_island_end(0));
3407
3408 let slots = SlotData::new(0);
3409 let html = walk_with_islands(
3410 &["div", "Simple"],
3411 &[],
3412 &[(0, 0x01, 0x01, 1, 0, &[])], &opcodes,
3414 &slots,
3415 );
3416 assert!(
3417 !html.contains("data-forma-props"),
3418 "empty slot_ids should not emit props, got: {html}"
3419 );
3420 assert!(
3421 html.contains(r#"data-forma-island="0""#),
3422 "should contain island id"
3423 );
3424 }
3425
3426 fn walk_island_with_prop_payload(payload: &str) -> String {
3430 let mut opcodes = Vec::new();
3431 opcodes.extend_from_slice(&encode_island_start(0));
3432 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3433 opcodes.extend_from_slice(&encode_close_tag(0));
3434 opcodes.extend_from_slice(&encode_island_end(0));
3435
3436 let mut slots = SlotData::new(1);
3437 slots.set(0, SlotValue::Text(payload.to_string()));
3438 walk_with_islands(
3439 &["div", "Big", "blob"],
3440 &[(0, 2, 0x01, 0x00, &[])],
3441 &[(0, 0x01, 0x01, 1, 0, &[0])],
3442 &opcodes,
3443 &slots,
3444 )
3445 }
3446
3447 #[test]
3448 fn island_props_keep_the_first_slot_for_a_duplicated_name() {
3449 let mut opcodes = Vec::new();
3453 opcodes.extend_from_slice(&encode_island_start(0));
3454 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3455 opcodes.extend_from_slice(&encode_close_tag(0));
3456 opcodes.extend_from_slice(&encode_island_end(0));
3457
3458 let mut slots = SlotData::new(2);
3459 slots.set(0, SlotValue::Text("first".into()));
3460 slots.set(1, SlotValue::Text("second".into()));
3461
3462 let html = walk_with_islands(
3463 &["div", "Comp", "dup"],
3464 &[(0, 2, 0x01, 0x00, &[]), (1, 2, 0x01, 0x00, &[])],
3465 &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
3466 &opcodes,
3467 &slots,
3468 );
3469 assert!(
3470 html.contains(r#"data-forma-props="{"dup":"first"}""#),
3471 "expected the first slot to win, got: {html}"
3472 );
3473 }
3474
3475 #[test]
3476 fn inline_props_under_the_ceiling_stay_inline() {
3477 let html = walk_island_with_prop_payload("small");
3478 assert!(html.contains(r#"data-forma-props="{"blob":"small"}""#));
3479 assert!(!html.contains("__forma_islands"));
3480 }
3481
3482 #[test]
3483 fn inline_props_over_the_ceiling_spill_to_the_shared_block() {
3484 let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
3490 let html = walk_island_with_prop_payload(&payload);
3491 assert!(
3492 !html.contains("data-forma-props"),
3493 "over-size props must not stay in the attribute"
3494 );
3495 assert!(
3496 html.contains(r#"<script id="__forma_islands" type="application/json">"#),
3497 "over-size props must land in the shared block, got: {}",
3498 &html[..html.len().min(200)]
3499 );
3500 assert!(html.contains(&payload), "the payload itself must survive");
3501 }
3502
3503 #[test]
3504 fn island_fragment_keeps_over_size_props_inline() {
3505 let mut opcodes = Vec::new();
3509 opcodes.extend_from_slice(&encode_island_start(0));
3510 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3511 opcodes.extend_from_slice(&encode_close_tag(0));
3512 opcodes.extend_from_slice(&encode_island_end(0));
3513
3514 let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
3515 let mut slots = SlotData::new(1);
3516 slots.set(0, SlotValue::Text(payload));
3517
3518 let data = build_minimal_ir(
3519 &["div", "Big", "blob"],
3520 &[(0, 2, 0x01, 0x00, &[])],
3521 &opcodes,
3522 &[(0, 0x01, 0x01, 1, 0, &[0])],
3523 );
3524 let module = IrModule::parse(&data).unwrap();
3525 let fragment = walk_island(&module, &slots, 0).unwrap();
3526 assert!(fragment.contains("data-forma-props"));
3527 assert!(!fragment.contains("__forma_islands"));
3528 }
3529
3530 #[test]
3535 fn walk_multiple_islands_script_tag() {
3536 let mut opcodes = Vec::new();
3540 opcodes.extend_from_slice(&encode_island_start(0));
3542 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3543 opcodes.extend_from_slice(&encode_close_tag(0));
3544 opcodes.extend_from_slice(&encode_island_end(0));
3545 let island1_offset = opcodes.len() as u32;
3547 opcodes.extend_from_slice(&encode_island_start(1));
3548 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3549 opcodes.extend_from_slice(&encode_close_tag(1));
3550 opcodes.extend_from_slice(&encode_island_end(1));
3551
3552 let mut slots = SlotData::new(3);
3553 slots.set(0, SlotValue::Text("hello".to_string()));
3554 slots.set(1, SlotValue::Bool(true));
3555 slots.set(2, SlotValue::Text("x".repeat(INLINE_PROPS_MAX_BYTES)));
3556
3557 let html = walk_with_islands(
3558 &["div", "span", "CompA", "CompB", "msg", "flag", "padding"],
3559 &[
3560 (0, 4, 0x01, 0x00, &[]), (1, 5, 0x02, 0x00, &[]),
3562 (2, 6, 0x01, 0, &[]), ],
3564 &[
3565 (0, 0x01, 0x02, 2, 0, &[0, 2]), (1, 0x01, 0x02, 3, island1_offset, &[1, 2]), ],
3568 &opcodes,
3569 &slots,
3570 );
3571 let script_start =
3573 html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
3574 let script_end = html[script_start..].find("</script>").unwrap() + script_start;
3575 let json: serde_json::Value =
3576 serde_json::from_str(&html[script_start..script_end]).unwrap();
3577 assert_eq!(json["0"]["msg"], "hello");
3578 assert_eq!(json["1"]["flag"], true);
3579 }
3580
3581 #[test]
3586 fn slot_value_to_json_all_types() {
3587 assert_eq!(SlotValue::Null.to_json(), serde_json::Value::Null);
3589 assert_eq!(
3590 SlotValue::Text("hello".to_string()).to_json(),
3591 serde_json::Value::String("hello".to_string())
3592 );
3593 assert_eq!(
3594 SlotValue::Bool(true).to_json(),
3595 serde_json::Value::Bool(true)
3596 );
3597 assert_eq!(
3598 SlotValue::Bool(false).to_json(),
3599 serde_json::Value::Bool(false)
3600 );
3601 assert_eq!(SlotValue::Number(42.0).to_json(), serde_json::json!(42));
3602 assert_eq!(SlotValue::Number(3.15).to_json(), serde_json::json!(3.15));
3603 assert_eq!(
3604 SlotValue::array(vec![
3605 SlotValue::Text("a".to_string()),
3606 SlotValue::Number(1.0)
3607 ])
3608 .to_json(),
3609 serde_json::json!(["a", 1])
3610 );
3611 assert_eq!(
3612 SlotValue::object(vec![(
3613 "key".to_string(),
3614 SlotValue::Text("val".to_string())
3615 ),])
3616 .to_json(),
3617 serde_json::json!({"key": "val"})
3618 );
3619 }
3620
3621 #[test]
3624 fn dyn_attr_emits_attribute_with_value() {
3625 let mut opcodes = Vec::new();
3629 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3630 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
3631 opcodes.extend_from_slice(&encode_close_tag(0));
3632
3633 let mut slots = SlotData::new(1);
3634 slots.set(0, SlotValue::Text("password".to_string()));
3635
3636 let html = walk_with_slots(
3637 &["input", "type", "input_type"],
3638 &[(0, 2, 0x01, 0x01, b"password")], &opcodes,
3640 &slots,
3641 );
3642 assert_eq!(html, r#"<input type="password"></input>"#);
3643 }
3644
3645 #[test]
3648 fn dyn_attr_skips_when_slot_null() {
3649 let mut opcodes = Vec::new();
3653 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3654 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
3655 opcodes.extend_from_slice(&encode_close_tag(0));
3656
3657 let slots = SlotData::new(1); let html = walk_with_slots(
3660 &["div", "class", "cls"],
3661 &[(0, 2, 0x01, 0x00, &[])],
3662 &opcodes,
3663 &slots,
3664 );
3665 assert_eq!(html, "<div></div>");
3666 }
3667
3668 #[test]
3671 fn dyn_attr_multiple_on_same_element() {
3672 let mut opcodes = Vec::new();
3675 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3676 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
3677 opcodes.extend_from_slice(&encode_dyn_attr(2, 1));
3678 opcodes.extend_from_slice(&encode_close_tag(0));
3679
3680 let mut slots = SlotData::new(2);
3681 slots.set(0, SlotValue::Text("email".to_string()));
3682 slots.set(1, SlotValue::Text("form-input".to_string()));
3683
3684 let html = walk_with_slots(
3685 &["input", "type", "class", "slot_type", "slot_class"],
3686 &[(0, 3, 0x01, 0x01, b"text"), (1, 4, 0x01, 0x01, b"")],
3687 &opcodes,
3688 &slots,
3689 );
3690 assert_eq!(html, r#"<input type="email" class="form-input"></input>"#);
3691 }
3692
3693 #[test]
3696 fn dyn_attr_on_void_tag() {
3697 let mut opcodes = Vec::new();
3701 opcodes.extend_from_slice(&encode_void_tag(0, &[]));
3702 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
3703
3704 let mut slots = SlotData::new(1);
3705 slots.set(0, SlotValue::Text("checkbox".to_string()));
3706
3707 let html = walk_with_slots(
3708 &["input", "type", "slot_type"],
3709 &[(0, 2, 0x01, 0x01, b"text")],
3710 &opcodes,
3711 &slots,
3712 );
3713 assert_eq!(html, r#"<input type="checkbox">"#);
3714 }
3715
3716 #[test]
3719 fn walk_island_basic() {
3720 let pre_island = encode_open_tag(1, &[]); let island_start = encode_island_start(0);
3735 let open_p = encode_open_tag(2, &[]); let text_hello = encode_text(3);
3737 let close_p = encode_close_tag(2);
3738 let island_end = encode_island_end(0);
3739 let close_div = encode_close_tag(1);
3740
3741 let byte_offset = pre_island.len() as u32; let mut opcodes = Vec::new();
3744 opcodes.extend_from_slice(&pre_island);
3745 opcodes.extend_from_slice(&island_start);
3746 opcodes.extend_from_slice(&open_p);
3747 opcodes.extend_from_slice(&text_hello);
3748 opcodes.extend_from_slice(&close_p);
3749 opcodes.extend_from_slice(&island_end);
3750 opcodes.extend_from_slice(&close_div);
3751
3752 let data = build_minimal_ir(
3753 &["MyIsland", "div", "p", "hello"],
3754 &[],
3755 &opcodes,
3756 &[(0, 0x01, 0x01, 0, byte_offset, &[])], );
3758 let module = IrModule::parse(&data).unwrap();
3759 let slots = SlotData::new(0);
3760
3761 let html = walk_island(&module, &slots, 0).unwrap();
3762 assert_eq!(
3766 html,
3767 "<p data-forma-island=\"0\" data-forma-component=\"MyIsland\" \
3768 data-forma-status=\"pending\" data-forma-hydrate=\"load\">hello</p>"
3769 );
3770 assert!(!html.contains("<!--f:i0-->"));
3773 assert!(!html.contains("<!--/f:i0-->"));
3774 }
3775
3776 #[test]
3777 fn walk_island_attrs_match_walk_to_html() {
3778 let island_start = encode_island_start(0);
3781 let open_div = encode_open_tag(1, &[]);
3782 let dyn_text = encode_dyn_text(0, 0);
3783 let close_div = encode_close_tag(1);
3784 let island_end = encode_island_end(0);
3785
3786 let mut opcodes = Vec::new();
3787 opcodes.extend_from_slice(&island_start);
3788 opcodes.extend_from_slice(&open_div);
3789 opcodes.extend_from_slice(&dyn_text);
3790 opcodes.extend_from_slice(&close_div);
3791 opcodes.extend_from_slice(&island_end);
3792
3793 let data = build_minimal_ir(
3794 &["IslandComp", "div", "greeting"],
3795 &[(0, 2, 0x01, 0x00, &[])],
3796 &opcodes,
3797 &[(0, 0x02, 0x01, 0, 0, &[0])], );
3799 let module = IrModule::parse(&data).unwrap();
3800 let mut slots = SlotData::new(1);
3801 slots.set(0, SlotValue::Text("World".to_string()));
3802
3803 let page = walk_to_html(&module, &slots).unwrap();
3804 let fragment = walk_island(&module, &slots, 0).unwrap();
3805
3806 for attr in [
3807 "data-forma-island=\"0\"",
3808 "data-forma-component=\"IslandComp\"",
3809 "data-forma-status=\"pending\"",
3810 "data-forma-hydrate=\"visible\"",
3811 "data-forma-props=",
3812 ] {
3813 assert!(page.contains(attr), "page missing {attr}: {page}");
3814 assert!(
3815 fragment.contains(attr),
3816 "fragment missing {attr}: {fragment}"
3817 );
3818 }
3819 }
3820
3821 #[test]
3822 fn walk_island_script_tag_mode_inlines_props_in_fragment() {
3823 let mut opcodes = Vec::new();
3829 opcodes.extend_from_slice(&encode_island_start(0));
3830 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3831 opcodes.extend_from_slice(&encode_close_tag(1));
3832 opcodes.extend_from_slice(&encode_island_end(0));
3833
3834 let data = build_minimal_ir(
3835 &["IslandComp", "div", "greeting"],
3836 &[(0, 2, 0x01, 0x00, &[])],
3837 &opcodes,
3838 &[(0, 0x01, 0x02, 0, 0, &[0])], );
3840 let module = IrModule::parse(&data).unwrap();
3841 let mut slots = SlotData::new(1);
3842 slots.set(0, SlotValue::Text("World".to_string()));
3843
3844 let fragment = walk_island(&module, &slots, 0).unwrap();
3845 assert!(
3846 fragment.contains("data-forma-props="),
3847 "ScriptTag island fragment must inline its props: {fragment}"
3848 );
3849 assert!(
3850 !fragment.contains("__forma_islands"),
3851 "fragment must not emit a second islands block: {fragment}"
3852 );
3853 }
3854
3855 #[test]
3856 fn walk_island_nested_script_tag_island_keeps_its_props() {
3857 let mut opcodes = encode_island_start(0);
3861 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3862 let inner_offset = opcodes.len() as u32;
3863 opcodes.extend_from_slice(&encode_island_start(1));
3864 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3865 opcodes.extend_from_slice(&encode_close_tag(1));
3866 opcodes.extend_from_slice(&encode_island_end(1));
3867 opcodes.extend_from_slice(&encode_close_tag(1));
3868 opcodes.extend_from_slice(&encode_island_end(0));
3869
3870 let data = build_minimal_ir(
3871 &["Outer", "div", "greeting", "Inner"],
3872 &[(0, 2, 0x01, 0x00, &[])],
3873 &opcodes,
3874 &[
3875 (0, 0x01, 0x01, 0, 0, &[]),
3876 (1, 0x01, 0x02, 3, inner_offset, &[0]), ],
3878 );
3879 let module = IrModule::parse(&data).unwrap();
3880 let mut slots = SlotData::new(1);
3881 slots.set(0, SlotValue::Text("World".into()));
3882
3883 let fragment = walk_island(&module, &slots, 0).unwrap();
3884 assert!(
3885 fragment.contains("data-forma-component=\"Inner\""),
3886 "nested island must render: {fragment}"
3887 );
3888 assert!(
3889 fragment.contains("data-forma-props="),
3890 "nested ScriptTag island must keep its props inline: {fragment}"
3891 );
3892 assert!(!fragment.contains("__forma_islands"), "{fragment}");
3893 }
3894
3895 #[test]
3896 fn walk_island_not_found() {
3897 let opcodes = encode_text(0);
3899 let data = build_minimal_ir(&["Hello"], &[], &opcodes, &[]);
3900 let module = IrModule::parse(&data).unwrap();
3901 let slots = SlotData::new(0);
3902
3903 let result = walk_island(&module, &slots, 5);
3904 assert!(result.is_err());
3905 match result.unwrap_err() {
3906 IrError::IslandNotFound(5) => {} other => panic!("expected IslandNotFound(5), got {other:?}"),
3908 }
3909 }
3910
3911 #[test]
3912 fn walk_island_with_dyn_text() {
3913 let island_start = encode_island_start(0);
3918 let open_div = encode_open_tag(1, &[]); let dyn_text = encode_dyn_text(0, 0); let close_div = encode_close_tag(1);
3921 let island_end = encode_island_end(0);
3922
3923 let byte_offset = 0u32; let mut opcodes = Vec::new();
3926 opcodes.extend_from_slice(&island_start);
3927 opcodes.extend_from_slice(&open_div);
3928 opcodes.extend_from_slice(&dyn_text);
3929 opcodes.extend_from_slice(&close_div);
3930 opcodes.extend_from_slice(&island_end);
3931
3932 let data = build_minimal_ir(
3933 &["IslandComp", "div", "greeting"],
3934 &[(0, 2, 0x01, 0x00, &[])], &opcodes,
3936 &[(0, 0x01, 0x01, 0, byte_offset, &[0])], );
3938 let module = IrModule::parse(&data).unwrap();
3939
3940 let mut slots = SlotData::new(1);
3941 slots.set(0, SlotValue::Text("World".to_string()));
3942
3943 let html = walk_island(&module, &slots, 0).unwrap();
3944 assert_eq!(
3946 html,
3947 "<div data-forma-island=\"0\" data-forma-component=\"IslandComp\" \
3948 data-forma-status=\"pending\" data-forma-hydrate=\"load\" \
3949 data-forma-props=\"{"greeting":"World"}\">\
3950 <!--f:t0-->World<!--/f:t0--></div>"
3951 );
3952 }
3953
3954 #[test]
3955 fn walk_island_byte_offset_out_of_bounds() {
3956 let opcodes = encode_text(0); let data = build_minimal_ir(
3964 &["BadIsland", "hello"],
3965 &[],
3966 &opcodes,
3967 &[(0, 0x01, 0x01, 0, 9999, &[])], );
3969 let err = IrModule::parse(&data).unwrap_err();
3970 assert_eq!(
3971 err,
3972 IrError::IslandOffsetMismatch {
3973 island_id: 0,
3974 byte_offset: 9999
3975 }
3976 );
3977 }
3978
3979 fn encode_prop(src_slot_id: u16, prop_str_idx: u32, target_slot_id: u16) -> Vec<u8> {
3983 let mut buf = vec![0x12]; buf.extend_from_slice(&src_slot_id.to_le_bytes());
3985 buf.extend_from_slice(&prop_str_idx.to_le_bytes());
3986 buf.extend_from_slice(&target_slot_id.to_le_bytes());
3987 buf
3988 }
3989
3990 #[test]
3993 fn walk_prop_in_list_body() {
3994 let mut body = Vec::new();
4001 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);
4004
4005 let mut slots = SlotData::new(3);
4006 slots.set(
4007 0,
4008 SlotValue::array(vec![
4009 SlotValue::object(vec![(
4010 "name".to_string(),
4011 SlotValue::Text("Alice".to_string()),
4012 )]),
4013 SlotValue::object(vec![(
4014 "name".to_string(),
4015 SlotValue::Text("Bob".to_string()),
4016 )]),
4017 ]),
4018 );
4019
4020 let html = walk_with_slots(
4021 &["items", "item", "name"],
4022 &[
4023 (0, 0, 0x04, 0x00, &[]), (1, 1, 0x05, 0x00, &[]), (2, 2, 0x01, 0x00, &[]), ],
4027 &opcodes,
4028 &slots,
4029 );
4030 assert!(html.contains("Alice"), "should contain Alice, got: {html}");
4031 assert!(html.contains("Bob"), "should contain Bob, got: {html}");
4032 }
4033
4034 #[test]
4037 fn walk_prop_multiple_properties() {
4038 let mut body = Vec::new();
4045 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);
4056
4057 let mut slots = SlotData::new(4);
4058 slots.set(
4059 0,
4060 SlotValue::array(vec![
4061 SlotValue::object(vec![
4062 ("name".to_string(), SlotValue::Text("Alice".to_string())),
4063 (
4064 "email".to_string(),
4065 SlotValue::Text("alice@test.com".to_string()),
4066 ),
4067 ]),
4068 SlotValue::object(vec![
4069 ("name".to_string(), SlotValue::Text("Bob".to_string())),
4070 (
4071 "email".to_string(),
4072 SlotValue::Text("bob@test.com".to_string()),
4073 ),
4074 ]),
4075 ]),
4076 );
4077
4078 let html = walk_with_slots(
4079 &["rows", "row", "name", "email", "tr", "td"],
4080 &[
4081 (0, 0, 0x04, 0x00, &[]),
4082 (1, 1, 0x05, 0x00, &[]),
4083 (2, 2, 0x01, 0x00, &[]),
4084 (3, 3, 0x01, 0x00, &[]),
4085 ],
4086 &opcodes,
4087 &slots,
4088 );
4089 assert!(
4090 html.contains("<td><!--f:t0-->Alice<!--/f:t0--></td>"),
4091 "should contain Alice td, got: {html}"
4092 );
4093 assert!(
4094 html.contains("<td><!--f:t1-->alice@test.com<!--/f:t1--></td>"),
4095 "should contain alice email, got: {html}"
4096 );
4097 assert!(
4098 html.contains("<td><!--f:t0-->Bob<!--/f:t0--></td>"),
4099 "should contain Bob td, got: {html}"
4100 );
4101 assert!(
4102 html.contains("<td><!--f:t1-->bob@test.com<!--/f:t1--></td>"),
4103 "should contain bob email, got: {html}"
4104 );
4105 }
4106
4107 #[test]
4110 fn walk_prop_on_non_object() {
4111 let mut opcodes = Vec::new();
4114 opcodes.extend_from_slice(&encode_prop(0, 1, 1));
4115 opcodes.extend_from_slice(&encode_dyn_text(1, 0));
4116
4117 let mut slots = SlotData::new(2);
4118 slots.set(0, SlotValue::Text("hello".to_string()));
4119
4120 let html = walk_with_slots(
4121 &["src", "name"],
4122 &[(0, 0, 0x01, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4123 &opcodes,
4124 &slots,
4125 );
4126 assert!(
4128 html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4129 "non-object PROP should produce empty text, got: {html}"
4130 );
4131 }
4132
4133 #[test]
4136 fn walk_prop_missing_property() {
4137 let mut opcodes = Vec::new();
4140 opcodes.extend_from_slice(&encode_prop(0, 2, 1));
4141 opcodes.extend_from_slice(&encode_dyn_text(1, 0));
4142
4143 let mut slots = SlotData::new(2);
4144 slots.set(
4145 0,
4146 SlotValue::object(vec![(
4147 "name".to_string(),
4148 SlotValue::Text("Alice".to_string()),
4149 )]),
4150 );
4151
4152 let html = walk_with_slots(
4153 &["src", "target", "missing"],
4154 &[(0, 0, 0x05, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4155 &opcodes,
4156 &slots,
4157 );
4158 assert!(
4159 html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4160 "missing property should produce empty text, got: {html}"
4161 );
4162 }
4163
4164 #[test]
4167 fn walk_nested_show_if_within_limit() {
4168 let inner = encode_text(5);
4172 let level4 = encode_show_if(4, &inner, &[]);
4173 let level3 = encode_show_if(3, &level4, &[]);
4174 let level2 = encode_show_if(2, &level3, &[]);
4175 let level1 = encode_show_if(1, &level2, &[]);
4176 let level0 = encode_show_if(0, &level1, &[]);
4177
4178 let mut slots = SlotData::new(5);
4179 for i in 0..5 {
4180 slots.set(i, SlotValue::Bool(true));
4181 }
4182
4183 let html = walk_with_slots(
4184 &["s0", "s1", "s2", "s3", "s4", "deep"],
4185 &[
4186 (0, 0, 0x02, 0x00, &[]),
4187 (1, 1, 0x02, 0x00, &[]),
4188 (2, 2, 0x02, 0x00, &[]),
4189 (3, 3, 0x02, 0x00, &[]),
4190 (4, 4, 0x02, 0x00, &[]),
4191 ],
4192 &level0,
4193 &slots,
4194 );
4195 assert!(
4196 html.contains("deep"),
4197 "5 levels of nesting should succeed, got: {html}"
4198 );
4199 }
4200
4201 fn script_tag_props_html(payload: &str) -> (String, String) {
4204 let mut opcodes = Vec::new();
4207 opcodes.extend_from_slice(&encode_island_start(0));
4208 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4209 opcodes.extend_from_slice(&encode_close_tag(0));
4210 opcodes.extend_from_slice(&encode_island_end(0));
4211
4212 let mut slots = SlotData::new(2);
4213 slots.set(0, SlotValue::Text(payload.to_string()));
4214 slots.set(1, SlotValue::Text("x".repeat(INLINE_PROPS_MAX_BYTES)));
4215
4216 let html = walk_with_islands(
4217 &["div", "Comp", "payload", "padding"],
4218 &[(0, 2, 0x01, 0x00, &[]), (1, 3, 0x01, 0, &[])],
4219 &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
4220 &opcodes,
4221 &slots,
4222 );
4223
4224 const OPEN: &str = "type=\"application/json\">";
4225 let start = html.find(OPEN).expect("islands script block") + OPEN.len();
4226 let end = html[start..].find("</script>").expect("script close") + start;
4227 let body = html[start..end].to_string();
4228 (html, body)
4229 }
4230
4231 #[test]
4232 fn walk_script_tag_props_body_has_no_raw_angle_brackets() {
4233 let (_html, body) = script_tag_props_html("</script>alert(1)");
4238 assert!(
4239 !body.contains('<') && !body.contains('>'),
4240 "islands block must contain no raw angle brackets, got: {body}"
4241 );
4242 assert!(body.contains("\\u003c/script\\u003e"), "got: {body}");
4243
4244 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4248 assert_eq!(parsed["0"]["payload"], "</script>alert(1)");
4249 }
4250
4251 #[test]
4252 fn walk_script_tag_props_escapes_comment_script_breakout() {
4253 let payload = "<!--<script>PAYLOAD";
4259 let (html, body) = script_tag_props_html(payload);
4260
4261 assert!(
4262 !html.contains("<!--<script"),
4263 "must not emit the script-data-double-escape trigger, got: {html}"
4264 );
4265 assert!(!body.contains('<') && !body.contains('>'), "got: {body}");
4266
4267 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4268 assert_eq!(parsed["0"]["payload"], payload);
4269 }
4270
4271 #[test]
4272 #[ignore = "requires admin/dist IR files — run in monorepo or after `npm run build`"]
4273 fn walk_real_benchmark_ir() {
4274 let dist_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../admin/dist");
4276 let ir_path = std::fs::read_dir(&dist_dir)
4277 .expect("admin/dist must exist")
4278 .filter_map(|e| e.ok())
4279 .find(|e| {
4280 let name = e.file_name();
4281 let s = name.to_string_lossy();
4282 s.starts_with("platform-benchmark.") && s.ends_with(".ir")
4283 })
4284 .map(|e| e.path())
4285 .expect("platform-benchmark.*.ir must exist in admin/dist");
4286 let data = std::fs::read(&ir_path).expect("benchmark IR file must exist");
4287 let module = IrModule::parse(&data).expect("IR must parse");
4288
4289 let slots = SlotData::new_from_defaults(&module.slots);
4291 let html = walk_to_html(&module, &slots).expect("walk must succeed");
4292
4293 assert!(html.contains("<!--f:i0-->"), "missing island 0 start");
4295 assert!(html.contains("<!--/f:i0-->"), "missing island 0 end");
4296 assert!(
4297 html.contains("data-forma-island=\"0\""),
4298 "missing island 0 attr"
4299 );
4300
4301 assert!(
4304 html.contains("Search"),
4305 "FilterBar must contain 'Search' label text in SSR"
4306 );
4307 assert!(
4308 html.contains("filter-group"),
4309 "FilterBar must contain filter-group divs in SSR"
4310 );
4311 assert!(
4312 html.contains("Sort by"),
4313 "FilterBar must contain 'Sort by' label text in SSR"
4314 );
4315
4316 assert!(
4318 html.contains("Performance"),
4319 "PerfPanel must contain 'Performance' heading in SSR"
4320 );
4321
4322 assert!(
4325 html.contains("benchmark-data-table"),
4326 "BenchmarkDataTable root class must exist"
4327 );
4328
4329 let start = html
4333 .find("<!--f:i0-->")
4334 .expect("FilterBar island start marker");
4335 let end = html
4336 .find("<!--/f:i0-->")
4337 .expect("FilterBar island end marker");
4338 assert!(end > start, "island end marker must follow its start");
4339 }
4340
4341 #[test]
4344 fn walk_output_limit_trips_inside_one_opcode() {
4345 let big = "&".repeat(4096); let attrs: Vec<(u32, u32)> = (0..200).map(|_| (1u32, 2u32)).collect();
4351 let opcodes = encode_open_tag(0, &attrs);
4352
4353 let data = build_minimal_ir(&["div", "data-x", &big], &[], &opcodes, &[]);
4354 let module = IrModule::parse(&data).unwrap();
4355 let slots = SlotData::new(0);
4356
4357 let limits = WalkLimits {
4358 max_output_bytes: 64 * 1024,
4359 ..WalkLimits::default()
4360 };
4361 let err = walk_to_html_with_limits(&module, &slots, limits).unwrap_err();
4362 assert_eq!(err, IrError::OutputLimitExceeded { limit: 64 * 1024 });
4363
4364 let ok = WalkLimits {
4366 max_output_bytes: 64 * 1024 * 1024,
4367 ..WalkLimits::default()
4368 };
4369 assert!(walk_to_html_with_limits(&module, &slots, ok).is_ok());
4370 }
4371
4372 #[test]
4373 fn walk_output_limit_trips_on_a_single_dyn_text_value() {
4374 let opcodes = encode_dyn_text(0, 0);
4376 let data = build_minimal_ir(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &[]);
4377 let module = IrModule::parse(&data).unwrap();
4378
4379 let mut slots = SlotData::new(1);
4380 slots.set(0, SlotValue::Text("x".repeat(100_000)));
4381
4382 let limits = WalkLimits {
4383 max_output_bytes: 4096,
4384 ..WalkLimits::default()
4385 };
4386 assert!(matches!(
4387 walk_to_html_with_limits(&module, &slots, limits),
4388 Err(IrError::OutputLimitExceeded { .. })
4389 ));
4390 }
4391
4392 #[test]
4395 fn walk_list_budget_exceeded() {
4396 let body = encode_text(2);
4399 let inner = encode_list(0, 1, &body);
4400 let opcodes = encode_list(0, 1, &inner);
4401
4402 let data = build_minimal_ir(
4403 &["items", "item", "x"],
4404 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4405 &opcodes,
4406 &[],
4407 );
4408 let module = IrModule::parse(&data).unwrap();
4409
4410 let mut slots = SlotData::new(2);
4411 slots.set(
4412 0,
4413 SlotValue::array((0..300).map(|_| SlotValue::Text("a".into())).collect()),
4414 );
4415
4416 let err = walk_to_html(&module, &slots).unwrap_err();
4417 assert_eq!(
4418 err,
4419 IrError::ListBudgetExceeded {
4420 max: DEFAULT_MAX_LIST_ITERATIONS
4421 }
4422 );
4423 assert_ne!(
4427 err,
4428 IrError::ListDepthExceeded {
4429 max: MAX_LIST_DEPTH
4430 }
4431 );
4432 }
4433
4434 fn patch_u32(buf: &mut [u8], at: usize, value: u32) {
4438 buf[at..at + 4].copy_from_slice(&value.to_le_bytes());
4439 }
4440
4441 #[test]
4442 fn parse_rejects_show_if_then_len_past_the_stream() {
4443 let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
4445 patch_u32(&mut opcodes, 3, 0xFFFF_FF00); let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
4447 assert!(matches!(
4448 IrModule::parse(&data),
4449 Err(IrError::MalformedControlFlow { .. })
4450 ));
4451 }
4452
4453 #[test]
4454 fn parse_rejects_show_if_marker_byte_that_is_not_show_else() {
4455 let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
4458 patch_u32(&mut opcodes, 3, 4); let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
4460 assert!(matches!(
4461 IrModule::parse(&data),
4462 Err(IrError::MalformedControlFlow { .. })
4463 ));
4464 }
4465
4466 #[test]
4467 fn parse_rejects_list_body_len_past_the_stream() {
4468 let mut opcodes = encode_list(0, 1, &encode_text(2));
4470 patch_u32(&mut opcodes, 5, 0xFFFF_FF00);
4471 let data = build_minimal_ir(
4472 &["items", "item", "x"],
4473 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4474 &opcodes,
4475 &[],
4476 );
4477 assert!(matches!(
4478 IrModule::parse(&data),
4479 Err(IrError::MalformedControlFlow { .. })
4480 ));
4481 }
4482
4483 #[test]
4484 fn parse_accepts_zero_length_else_branch_known_residual() {
4485 let opcodes = encode_show_if(0, &encode_text(1), &[]);
4491 let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
4492 assert!(IrModule::parse(&data).is_ok());
4493 }
4494
4495 #[test]
4500 fn walk_island_attrs_survive_an_empty_show_if_before_the_root() {
4501 let mut opcodes = encode_island_start(0);
4506 opcodes.extend_from_slice(&encode_show_if(0, &[], &[]));
4507 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4508 opcodes.extend_from_slice(&encode_close_tag(1));
4509 opcodes.extend_from_slice(&encode_island_end(0));
4510
4511 let slots = SlotData::new(1);
4512 let html = walk_with_islands(
4513 &["Comp", "div", "flag"],
4514 &[(0, 2, 0x02, 0x00, &[])],
4515 &[(0, 0x01, 0x01, 0, 0, &[])],
4516 &opcodes,
4517 &slots,
4518 );
4519 assert!(
4520 html.contains("<div data-forma-island=\"0\""),
4521 "island root must keep its attributes, got: {html}"
4522 );
4523 }
4524
4525 #[test]
4526 fn walk_island_attrs_do_not_leak_past_the_island_end() {
4527 let mut opcodes = encode_island_start(0);
4531 opcodes.extend_from_slice(&encode_island_end(0));
4532 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4533 opcodes.extend_from_slice(&encode_close_tag(1));
4534
4535 let slots = SlotData::new(0);
4536 let html = walk_with_islands(
4537 &["Comp", "div"],
4538 &[],
4539 &[(0, 0x01, 0x01, 0, 0, &[])],
4540 &opcodes,
4541 &slots,
4542 );
4543 assert!(
4544 !html.contains("data-forma-island"),
4545 "island attributes must not leak onto an element outside the island, got: {html}"
4546 );
4547 }
4548
4549 #[test]
4552 fn walk_rejects_hostile_tag_name() {
4553 let opcodes = encode_open_tag(0, &[]);
4556 let data = build_minimal_ir(&["div onload=\"alert(1)\""], &[], &opcodes, &[]);
4557 let module = IrModule::parse(&data).unwrap();
4558 let slots = SlotData::new(0);
4559 assert!(matches!(
4560 walk_to_html(&module, &slots),
4561 Err(IrError::InvalidName(_))
4562 ));
4563 }
4564
4565 #[test]
4566 fn walk_rejects_hostile_close_tag_name() {
4567 let mut opcodes = encode_open_tag(0, &[]);
4568 opcodes.extend_from_slice(&encode_close_tag(1));
4569 let data = build_minimal_ir(&["div", "div><script"], &[], &opcodes, &[]);
4570 let module = IrModule::parse(&data).unwrap();
4571 let slots = SlotData::new(0);
4572 assert!(matches!(
4573 walk_to_html(&module, &slots),
4574 Err(IrError::InvalidName(_))
4575 ));
4576 }
4577
4578 #[test]
4579 fn walk_drops_hostile_attribute_names() {
4580 let opcodes = encode_open_tag(0, &[(1, 2), (3, 2)]);
4583 let data = build_minimal_ir(&["div", "x\"><script", "v", "ok-attr"], &[], &opcodes, &[]);
4584 let module = IrModule::parse(&data).unwrap();
4585 let slots = SlotData::new(0);
4586 let html = walk_to_html(&module, &slots).unwrap();
4587 assert_eq!(html, "<div ok-attr=\"v\">");
4588 }
4589
4590 #[test]
4591 fn walk_drops_hostile_boolean_attribute_name() {
4592 let opcodes = encode_open_tag(0, &[(1, 2)]);
4594 let data = build_minimal_ir(&["div", "y\"><script", ""], &[], &opcodes, &[]);
4595 let module = IrModule::parse(&data).unwrap();
4596 let slots = SlotData::new(0);
4597 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
4598 }
4599
4600 #[test]
4601 fn walk_drops_hostile_dyn_attr_name() {
4602 let mut opcodes = encode_open_tag(0, &[]);
4603 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4604 let data = build_minimal_ir(
4605 &["div", "z\"><script", "val"],
4606 &[(0, 2, 0x01, 0x00, &[])],
4607 &opcodes,
4608 &[],
4609 );
4610 let module = IrModule::parse(&data).unwrap();
4611 let mut slots = SlotData::new(1);
4612 slots.set(0, SlotValue::Text("x".into()));
4613 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
4614 }
4615
4616 #[test]
4619 fn walk_drops_javascript_url_in_dyn_attr() {
4620 let mut opcodes = encode_open_tag(0, &[]);
4621 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4622 let data = build_minimal_ir(
4623 &["a", "href", "url"],
4624 &[(0, 2, 0x01, 0x00, &[])],
4625 &opcodes,
4626 &[],
4627 );
4628 let module = IrModule::parse(&data).unwrap();
4629
4630 for payload in [
4633 "javascript:alert(1)",
4634 "JaVaScRiPt:alert(1)",
4635 "java\tscript:alert(1)",
4636 " javascript:alert(1)",
4637 "vbscript:msgbox(1)",
4638 "data:text/html,<script>alert(1)</script>",
4639 ] {
4640 let mut slots = SlotData::new(1);
4641 slots.set(0, SlotValue::Text(payload.to_string()));
4642 let html = walk_to_html(&module, &slots).unwrap();
4643 assert_eq!(html, "<a>", "payload {payload:?} was not dropped");
4646 }
4647 }
4648
4649 #[test]
4650 fn walk_keeps_safe_urls_and_data_images() {
4651 let mut opcodes = encode_open_tag(0, &[]);
4652 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4653 let data = build_minimal_ir(
4654 &["img", "src", "url"],
4655 &[(0, 2, 0x01, 0x00, &[])],
4656 &opcodes,
4657 &[],
4658 );
4659 let module = IrModule::parse(&data).unwrap();
4660
4661 for payload in [
4662 "/about",
4663 "https://example.com/x?a=1",
4664 "data:image/png;base64,AAAA",
4665 "data:image/svg+xml,%3Csvg%3E%3C/svg%3E",
4669 ] {
4670 let mut slots = SlotData::new(1);
4671 slots.set(0, SlotValue::Text(payload.to_string()));
4672 let html = walk_to_html(&module, &slots).unwrap();
4673 assert!(html.contains("src="), "payload {payload:?} was dropped");
4674 }
4675 }
4676
4677 #[test]
4678 fn walk_blocks_svg_data_url_outside_image_context() {
4679 let mut opcodes = encode_open_tag(0, &[]);
4680 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4681 let data = build_minimal_ir(
4682 &["a", "href", "url"],
4683 &[(0, 2, 0x01, 0x00, &[])],
4684 &opcodes,
4685 &[],
4686 );
4687 let module = IrModule::parse(&data).unwrap();
4688 let mut slots = SlotData::new(1);
4689 slots.set(
4690 0,
4691 SlotValue::Text("data:image/svg+xml,%3Csvg onload=alert(1)%3E".into()),
4692 );
4693 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<a>");
4696 }
4697
4698 #[test]
4699 fn walk_drops_event_handler_attributes() {
4700 let opcodes = encode_open_tag(0, &[(1, 2)]);
4702 let data = build_minimal_ir(&["div", "onmouseover", "alert(1)"], &[], &opcodes, &[]);
4703 let module = IrModule::parse(&data).unwrap();
4704 assert_eq!(
4705 walk_to_html(&module, &SlotData::new(0)).unwrap(),
4706 "<div>",
4707 "static on* attribute must be dropped"
4708 );
4709
4710 let mut opcodes = encode_open_tag(0, &[]);
4712 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4713 let data = build_minimal_ir(
4714 &["div", "ONCLICK", "h"],
4715 &[(0, 2, 0x01, 0x00, &[])],
4716 &opcodes,
4717 &[],
4718 );
4719 let module = IrModule::parse(&data).unwrap();
4720 let mut slots = SlotData::new(1);
4721 slots.set(0, SlotValue::Text("alert(1)".into()));
4722 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
4723 }
4724
4725 #[test]
4726 fn walk_drops_javascript_url_in_static_attr() {
4727 let opcodes = encode_open_tag(0, &[(1, 2)]);
4731 let data = build_minimal_ir(&["a", "href", "javascript:alert(1)"], &[], &opcodes, &[]);
4732 let module = IrModule::parse(&data).unwrap();
4733 assert_eq!(walk_to_html(&module, &SlotData::new(0)).unwrap(), "<a>");
4734 }
4735
4736 struct Page {
4751 strings: Vec<String>,
4752 ops: Vec<u8>,
4753 slots: Vec<(u8, SlotValue)>,
4754 islands: Vec<(u16, u32, u32)>,
4755 }
4756
4757 impl Page {
4758 fn new() -> Self {
4759 Page {
4760 strings: Vec::new(),
4761 ops: Vec::new(),
4762 slots: Vec::new(),
4763 islands: Vec::new(),
4764 }
4765 }
4766
4767 fn intern(&mut self, s: &str) -> u32 {
4768 let index = match self.strings.iter().position(|x| x == s) {
4769 Some(i) => i,
4770 None => {
4771 self.strings.push(s.to_string());
4772 self.strings.len() - 1
4773 }
4774 };
4775 u32::try_from(index).unwrap()
4776 }
4777
4778 fn slot(&mut self, value: SlotValue) -> u16 {
4780 let type_byte = match &value {
4781 SlotValue::Null | SlotValue::Text(_) => 0x01,
4782 SlotValue::Bool(_) => 0x02,
4783 SlotValue::Number(_) => 0x03,
4784 SlotValue::Array(_) => 0x04,
4785 SlotValue::Object(_) => 0x05,
4786 };
4787 self.slots.push((type_byte, value));
4788 u16::try_from(self.slots.len() - 1).unwrap()
4789 }
4790
4791 fn open(&mut self, tag: &str, attrs: &[(&str, &str)]) -> &mut Self {
4792 let tag = self.intern(tag);
4793 let attrs: Vec<(u32, u32)> = attrs
4794 .iter()
4795 .map(|(k, v)| (self.intern(k), self.intern(v)))
4796 .collect();
4797 self.ops.extend_from_slice(&encode_open_tag(tag, &attrs));
4798 self
4799 }
4800
4801 fn close(&mut self, tag: &str) -> &mut Self {
4802 let tag = self.intern(tag);
4803 self.ops.extend_from_slice(&encode_close_tag(tag));
4804 self
4805 }
4806
4807 fn text(&mut self, text: &str) -> &mut Self {
4808 let text = self.intern(text);
4809 self.ops.extend_from_slice(&encode_text(text));
4810 self
4811 }
4812
4813 fn dyn_text(&mut self, slot: u16) -> &mut Self {
4815 self.ops.extend_from_slice(&encode_dyn_text(slot, slot));
4816 self
4817 }
4818
4819 fn dyn_attr(&mut self, name: &str, slot: u16) -> &mut Self {
4820 let name = self.intern(name);
4821 self.ops.extend_from_slice(&encode_dyn_attr(name, slot));
4822 self
4823 }
4824
4825 fn prop(&mut self, source: u16, name: &str, target: u16) -> &mut Self {
4827 let name = self.intern(name);
4828 self.ops
4829 .extend_from_slice(&encode_prop(source, name, target));
4830 self
4831 }
4832
4833 fn body(&mut self, f: impl FnOnce(&mut Self)) -> Vec<u8> {
4835 let outer = std::mem::take(&mut self.ops);
4836 f(self);
4837 std::mem::replace(&mut self.ops, outer)
4838 }
4839
4840 fn show_if(
4841 &mut self,
4842 slot: u16,
4843 then_ops: impl FnOnce(&mut Self),
4844 else_ops: impl FnOnce(&mut Self),
4845 ) -> &mut Self {
4846 let then_ops = self.body(then_ops);
4847 let else_ops = self.body(else_ops);
4848 self.ops
4849 .extend_from_slice(&encode_show_if(slot, &then_ops, &else_ops));
4850 self
4851 }
4852
4853 fn list(&mut self, slot: u16, item: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
4854 let body = self.body(body);
4855 self.ops.extend_from_slice(&encode_list(slot, item, &body));
4856 self
4857 }
4858
4859 fn island(&mut self, id: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
4862 let name = self.intern(&format!("Island{id}"));
4863 let offset = u32::try_from(self.ops.len()).unwrap();
4864 self.islands.push((id, name, offset));
4865 self.ops.extend_from_slice(&encode_island_start(id));
4866 body(self);
4867 self.ops.extend_from_slice(&encode_island_end(id));
4868 self
4869 }
4870
4871 fn module_and_slots(&self) -> (IrModule, SlotData) {
4872 let mut strings = self.strings.clone();
4873 let mut decls = Vec::new();
4874 for (id, (type_byte, _)) in self.slots.iter().enumerate() {
4875 strings.push(format!("slot{id}"));
4876 let name = u32::try_from(strings.len() - 1).unwrap();
4877 decls.push((u16::try_from(id).unwrap(), name, *type_byte, 0x00, &[][..]));
4878 }
4879 let islands: Vec<(u16, u8, u8, u32, u32, &[u16])> = self
4880 .islands
4881 .iter()
4882 .map(|&(id, name, offset)| (id, 0x01, 0x01, name, offset, &[][..]))
4883 .collect();
4884 let strings: Vec<&str> = strings.iter().map(String::as_str).collect();
4885 let data = build_minimal_ir(&strings, &decls, &self.ops, &islands);
4886 let module = IrModule::parse(&data).unwrap();
4887 let mut slots = SlotData::new(self.slots.len());
4888 for (id, (_, value)) in self.slots.iter().enumerate() {
4889 slots.set(u16::try_from(id).unwrap(), value.clone());
4890 }
4891 (module, slots)
4892 }
4893
4894 fn render(&self) -> String {
4895 let (module, slots) = self.module_and_slots();
4896 walk_to_html(&module, &slots).unwrap()
4897 }
4898
4899 fn render_island(&self, id: u16) -> String {
4900 let (module, slots) = self.module_and_slots();
4901 walk_island(&module, &slots, id).unwrap()
4902 }
4903 }
4904
4905 fn text(s: &str) -> SlotValue {
4906 SlotValue::Text(s.to_string())
4907 }
4908
4909 #[test]
4910 fn dyn_text_in_a_text_only_parent_is_written_without_markers() {
4911 for parent in ["textarea", "title", "style", "script", "TEXTAREA"] {
4916 let mut page = Page::new();
4917 let inside = page.slot(text("a<b&c"));
4918 let outside = page.slot(text("world"));
4919 page.open("div", &[])
4920 .open(parent, &[])
4921 .dyn_text(inside)
4922 .close(parent)
4923 .open("p", &[])
4924 .dyn_text(outside)
4925 .close("p")
4926 .close("div");
4927 assert_eq!(
4928 page.render(),
4929 format!(
4930 "<div><{parent}>a<b&c</{parent}>\
4931 <p><!--f:t1-->world<!--/f:t1--></p></div>"
4932 ),
4933 "inside <{parent}>"
4934 );
4935 }
4936 }
4937
4938 #[test]
4939 fn dyn_text_outside_the_text_only_parents_keeps_its_markers() {
4940 for parent in ["pre", "option", "span", "titles", "xtextarea", "noscript"] {
4943 let mut page = Page::new();
4944 let value = page.slot(text("hello"));
4945 page.open(parent, &[]).dyn_text(value).close(parent);
4946 assert_eq!(
4947 page.render(),
4948 format!("<{parent}><!--f:t0-->hello<!--/f:t0--></{parent}>"),
4949 "inside <{parent}>"
4950 );
4951 }
4952 }
4953
4954 #[test]
4955 fn an_empty_value_in_a_text_only_parent_writes_nothing() {
4956 let mut page = Page::new();
4959 let inside = page.slot(text(""));
4960 let outside = page.slot(text(""));
4961 page.open("textarea", &[])
4962 .dyn_text(inside)
4963 .close("textarea")
4964 .open("p", &[])
4965 .dyn_text(outside)
4966 .close("p");
4967 assert_eq!(
4968 page.render(),
4969 "<textarea></textarea><p><!--f:t1-->\u{200B}<!--/f:t1--></p>"
4970 );
4971 }
4972
4973 #[test]
4974 fn a_textarea_doubles_a_leading_line_feed() {
4975 fn textarea(build: impl FnOnce(&mut Page)) -> String {
4979 let mut page = Page::new();
4980 page.open("textarea", &[]);
4981 build(&mut page);
4982 page.close("textarea");
4983 page.render()
4984 }
4985 let cases: [(&str, String, &str); 5] = [
4986 (
4987 "a dynamic value",
4988 textarea(|p| {
4989 let v = p.slot(text("\nhello"));
4990 p.dyn_text(v);
4991 }),
4992 "<textarea>\n\nhello</textarea>",
4993 ),
4994 (
4995 "a CRLF value",
4996 textarea(|p| {
4997 let v = p.slot(text("\r\nhello"));
4998 p.dyn_text(v);
4999 }),
5000 "<textarea>\n\r\nhello</textarea>",
5001 ),
5002 (
5003 "an empty value first: the first written byte decides",
5004 textarea(|p| {
5005 let empty = p.slot(text(""));
5006 let v = p.slot(text("\nx"));
5007 p.dyn_text(empty).dyn_text(v);
5008 }),
5009 "<textarea>\n\nx</textarea>",
5010 ),
5011 (
5012 "an LF after other content is kept as is",
5013 textarea(|p| {
5014 let v = p.slot(text("\nb"));
5015 p.text("a").dyn_text(v);
5016 }),
5017 "<textarea>a\nb</textarea>",
5018 ),
5019 (
5020 "static text",
5021 textarea(|p| {
5022 p.text("\nstatic");
5023 }),
5024 "<textarea>\n\nstatic</textarea>",
5025 ),
5026 ];
5027 for (case, html, expected) in cases {
5028 assert_eq!(html, expected, "{case}");
5029 }
5030
5031 let mut page = Page::new();
5033 let v = page.slot(text("\nhello"));
5034 page.open("title", &[]).dyn_text(v).close("title");
5035 assert_eq!(page.render(), "<title>\nhello</title>");
5036 }
5037
5038 #[test]
5039 fn no_marker_kind_is_written_inside_a_text_only_parent() {
5040 let mut page = Page::new();
5043 let loading = page.slot(SlotValue::Bool(false));
5044 let name = page.slot(text("Ada"));
5045 let tags = page.slot(SlotValue::array(vec![text("x"), text("y")]));
5046 let tag = page.slot(SlotValue::Null);
5047 page.open("title", &[])
5048 .show_if(
5049 loading,
5050 |p| {
5051 p.text("Loading");
5052 },
5053 |p| {
5054 p.dyn_text(name);
5055 },
5056 )
5057 .text(" | ")
5058 .list(tags, tag, |p| {
5059 p.dyn_text(tag).text(";");
5060 })
5061 .close("title")
5062 .open("p", &[])
5063 .show_if(
5064 loading,
5065 |p| {
5066 p.text("Loading");
5067 },
5068 |p| {
5069 p.dyn_text(name);
5070 },
5071 )
5072 .close("p");
5073 assert_eq!(
5074 page.render(),
5075 "<title>Ada | x;y;</title>\
5076 <p><!--f:s0--><!--f:t1-->Ada<!--/f:t1--><!--/f:s0--></p>"
5077 );
5078
5079 let mut page = Page::new();
5081 page.open("textarea", &[])
5082 .island(0, |p| {
5083 p.text("x");
5084 })
5085 .close("textarea")
5086 .island(1, |p| {
5087 p.open("div", &[]).close("div");
5088 });
5089 assert_eq!(
5090 page.render(),
5091 "<textarea>x</textarea><!--f:i1--><div data-forma-island=\"1\" \
5092 data-forma-component=\"Island1\" data-forma-status=\"pending\" \
5093 data-forma-hydrate=\"load\"></div><!--/f:i1-->"
5094 );
5095 }
5096
5097 #[test]
5098 fn a_textarea_value_binding_is_written_as_its_text() {
5099 let cases = [
5103 (text("hello"), "hello"),
5104 (
5105 text("</textarea><script>x</script>"),
5106 "</textarea><script>x</script>",
5107 ),
5108 (text("\nhello"), "\n\nhello"),
5109 (SlotValue::Null, ""),
5110 (SlotValue::Number(0.0), "0"),
5111 (SlotValue::Bool(false), "false"),
5112 ];
5113 for (value, inner) in cases {
5114 let mut page = Page::new();
5115 let v = page.slot(value.clone());
5116 let note = page.slot(text("note"));
5117 page.open("textarea", &[("name", "t")])
5118 .dyn_attr("value", v)
5119 .close("textarea")
5120 .open("p", &[])
5121 .dyn_text(note)
5122 .close("p");
5123 assert_eq!(
5124 page.render(),
5125 format!(
5126 "<textarea name=\"t\">{inner}</textarea><p><!--f:t1-->note<!--/f:t1--></p>"
5127 ),
5128 "value {value:?}"
5129 );
5130 }
5131
5132 let mut page = Page::new();
5134 page.open("textarea", &[("name", "t"), ("value", "hello")])
5135 .close("textarea");
5136 assert_eq!(page.render(), "<textarea name=\"t\">hello</textarea>");
5137 }
5138
5139 #[test]
5140 fn a_textarea_value_binding_wins_over_its_children() {
5141 let mut page = Page::new();
5144 let v = page.slot(text("bound"));
5145 let child = page.slot(text("child"));
5146 page.open("textarea", &[])
5147 .dyn_attr("value", v)
5148 .dyn_text(child)
5149 .text(" default")
5150 .close("textarea")
5151 .open("p", &[])
5152 .dyn_text(child)
5153 .close("p");
5154 assert_eq!(
5155 page.render(),
5156 "<textarea>bound</textarea><p><!--f:t1-->child<!--/f:t1--></p>"
5157 );
5158 }
5159
5160 fn select_page(value: SlotValue) -> String {
5163 let mut page = Page::new();
5164 let v = page.slot(value);
5165 page.open("select", &[("name", "c")]).dyn_attr("value", v);
5166 for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
5167 page.open("option", &[("value", value)])
5168 .text(label)
5169 .close("option");
5170 }
5171 page.close("select");
5172 page.render()
5173 }
5174
5175 fn option(value: &str, label: &str, selected: bool) -> String {
5176 let selected = if selected { " selected" } else { "" };
5177 format!("<option value=\"{value}\"{selected}>{label}</option>")
5178 }
5179
5180 #[test]
5181 fn a_select_value_binding_selects_the_matching_option() {
5182 for (value, chosen) in [("a", "a"), ("b", "b"), ("c", "c"), ("z", "")] {
5185 let expected = format!(
5186 "<select name=\"c\">{}{}{}</select>",
5187 option("a", "Apple", chosen == "a"),
5188 option("b", "Banana", chosen == "b"),
5189 option("c", "Cherry", chosen == "c"),
5190 );
5191 assert_eq!(select_page(text(value)), expected, "value {value}");
5192 }
5193 }
5194
5195 #[test]
5196 fn a_select_value_binding_compares_as_the_client_string() {
5197 for (value, chosen) in [(SlotValue::Number(2.0), "2"), (SlotValue::Null, "")] {
5199 let mut page = Page::new();
5200 let v = page.slot(value.clone());
5201 page.open("select", &[]).dyn_attr("value", v);
5202 for (value, label) in [("", "Choose"), ("1", "One"), ("2", "Two")] {
5203 page.open("option", &[("value", value)])
5204 .text(label)
5205 .close("option");
5206 }
5207 page.close("select");
5208 let placeholder = if chosen.is_empty() {
5210 "<option value selected>Choose</option>"
5211 } else {
5212 "<option value>Choose</option>"
5213 };
5214 let expected = format!(
5215 "<select>{placeholder}{}{}</select>",
5216 option("1", "One", chosen == "1"),
5217 option("2", "Two", chosen == "2"),
5218 );
5219 assert_eq!(page.render(), expected, "value {value:?}");
5220 }
5221 }
5222
5223 #[test]
5224 fn a_select_value_binding_matches_an_option_without_a_value_by_its_text() {
5225 let render = |value: &str| {
5228 let mut page = Page::new();
5229 let v = page.slot(text(value));
5230 let label = page.slot(text("Blue"));
5231 page.open("select", &[])
5232 .dyn_attr("value", v)
5233 .open("option", &[])
5234 .text("Red")
5235 .close("option")
5236 .open("option", &[])
5237 .text(" Dark \n green ")
5238 .close("option")
5239 .open("option", &[])
5240 .dyn_text(label)
5241 .close("option")
5242 .close("select");
5243 page.render()
5244 };
5245 assert_eq!(
5246 render("Dark green"),
5247 "<select><option>Red</option><option selected> Dark \n green </option>\
5248 <option><!--f:t1-->Blue<!--/f:t1--></option></select>"
5249 );
5250 assert_eq!(
5251 render("Blue"),
5252 "<select><option>Red</option><option> Dark \n green </option>\
5253 <option selected><!--f:t1-->Blue<!--/f:t1--></option></select>"
5254 );
5255 }
5256
5257 #[test]
5258 fn a_select_value_binding_reaches_options_rendered_by_a_list() {
5259 let short: Vec<(String, String)> = [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")]
5264 .iter()
5265 .map(|(id, label)| (id.to_string(), label.to_string()))
5266 .collect();
5267 let long: Vec<(String, String)> = (0..45)
5268 .map(|i| (format!("r{i}"), format!("Row {i}")))
5269 .collect();
5270 let cases: [(&[(String, String)], &str, usize); 4] = [
5271 (&short, "a", 0),
5272 (&short, "c", 2),
5273 (&long, "r40", 40),
5274 (&long, "r44", 44),
5275 ];
5276 for (rows_in, value, chosen) in cases {
5277 let mut page = Page::new();
5278 let v = page.slot(text(value));
5279 let rows = rows_in
5280 .iter()
5281 .map(|(id, label)| {
5282 SlotValue::object(vec![
5283 ("id".to_string(), text(id)),
5284 ("label".to_string(), text(label)),
5285 ])
5286 })
5287 .collect();
5288 let items = page.slot(SlotValue::array(rows));
5289 let item = page.slot(SlotValue::Null);
5290 let id = page.slot(SlotValue::Null);
5291 let label = page.slot(SlotValue::Null);
5292 page.open("select", &[])
5293 .dyn_attr("value", v)
5294 .list(items, item, |p| {
5295 p.prop(item, "id", id)
5296 .prop(item, "label", label)
5297 .open("option", &[])
5298 .dyn_attr("value", id)
5299 .dyn_text(label)
5300 .close("option");
5301 })
5302 .close("select");
5303 let expected: String = rows_in
5304 .iter()
5305 .enumerate()
5306 .map(|(i, (id, label))| {
5307 let selected = if i == chosen { " selected" } else { "" };
5308 format!(
5309 "<option value=\"{id}\"{selected}><!--f:t4-->{label}<!--/f:t4--></option>"
5310 )
5311 })
5312 .collect();
5313 assert_eq!(
5314 page.render(),
5315 format!("<select><!--f:l1-->{expected}<!--/f:l1--></select>"),
5316 "value {value}"
5317 );
5318 }
5319 }
5320
5321 #[test]
5322 fn a_select_binding_ends_with_its_select() {
5323 let mut page = Page::new();
5328 let v = page.slot(text("b"));
5329 page.open("select", &[])
5330 .dyn_attr("value", v)
5331 .open("option", &[("value", "a")])
5332 .text("A")
5333 .close("option")
5334 .close("select")
5335 .open("datalist", &[])
5336 .open("option", &[("value", "b")])
5337 .text("B")
5338 .close("option")
5339 .close("datalist");
5340 assert_eq!(
5341 page.render(),
5342 format!(
5343 "<select>{}</select><datalist>{}</datalist>",
5344 option("a", "A", false),
5345 option("b", "B", false)
5346 )
5347 );
5348 }
5349
5350 #[test]
5351 fn an_options_first_value_attribute_is_its_value() {
5352 for (bound, selected) in [("a", " selected"), ("b", "")] {
5355 let mut page = Page::new();
5356 let v = page.slot(text(bound));
5357 let second = page.slot(text("b"));
5358 page.open("select", &[])
5359 .dyn_attr("value", v)
5360 .open("option", &[("value", "a")])
5361 .dyn_attr("value", second)
5362 .text("Only")
5363 .close("option")
5364 .close("select");
5365 assert_eq!(
5366 page.render(),
5367 format!("<select><option value=\"a\" value=\"b\"{selected}>Only</option></select>"),
5368 "bound to {bound}"
5369 );
5370 }
5371 }
5372
5373 #[test]
5374 fn multiple_counts_only_when_the_attribute_is_written() {
5375 for (multiple, attr, chosen) in [(false, "", false), (true, " multiple", true)] {
5380 let mut page = Page::new();
5381 let flag = page.slot(SlotValue::Bool(multiple));
5382 let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
5383 page.open("select", &[])
5384 .dyn_attr("multiple", flag)
5385 .dyn_attr("value", v);
5386 for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
5387 page.open("option", &[("value", value)])
5388 .text(label)
5389 .close("option");
5390 }
5391 page.close("select");
5392 assert_eq!(
5393 page.render(),
5394 format!(
5395 "<select{attr}>{}{}{}</select>",
5396 option("a", "Apple", chosen),
5397 option("b", "Banana", false),
5398 option("c", "Cherry", chosen)
5399 ),
5400 "multiple {multiple}"
5401 );
5402 }
5403 }
5404
5405 #[test]
5406 fn a_null_member_of_a_multiple_value_compares_as_null() {
5407 let mut page = Page::new();
5410 let v = page.slot(SlotValue::array(vec![SlotValue::Null, text("b")]));
5411 page.open("select", &[("multiple", "")])
5412 .dyn_attr("value", v);
5413 for (value, label) in [("null", "Null"), ("x", "Empty"), ("b", "Banana")] {
5414 page.open("option", &[("value", value)])
5415 .text(label)
5416 .close("option");
5417 }
5418 page.close("select");
5419 assert_eq!(
5423 page.render(),
5424 format!(
5425 "<select multiple>{}{}{}</select>",
5426 option("null", "Null", true),
5427 option("x", "Empty", false),
5428 option("b", "Banana", true)
5429 )
5430 );
5431 }
5432
5433 #[test]
5434 fn an_option_ends_at_the_next_option_or_optgroup() {
5435 let mut page = Page::new();
5439 let v = page.slot(text("A"));
5440 page.open("select", &[])
5441 .dyn_attr("value", v)
5442 .open("option", &[])
5443 .text("A")
5444 .open("option", &[])
5445 .text("B")
5446 .close("select");
5447 assert_eq!(
5448 page.render(),
5449 "<select><option selected>A<option>B</select>",
5450 "ended by <option>"
5451 );
5452
5453 let mut page = Page::new();
5454 let v = page.slot(text("A"));
5455 page.open("select", &[])
5456 .dyn_attr("value", v)
5457 .open("option", &[])
5458 .text("A")
5459 .open("optgroup", &[("label", "g")])
5460 .open("option", &[("value", "x")])
5461 .text("X")
5462 .close("option")
5463 .close("optgroup")
5464 .close("select");
5465 assert_eq!(
5466 page.render(),
5467 "<select><option selected>A<optgroup label=\"g\">\
5468 <option value=\"x\">X</option></optgroup></select>",
5469 "ended by <optgroup>"
5470 );
5471 }
5472
5473 #[test]
5474 fn a_single_select_selects_only_the_first_matching_option() {
5475 let mut page = Page::new();
5478 let v = page.slot(text("b"));
5479 page.open("select", &[])
5480 .dyn_attr("value", v)
5481 .open("option", &[("value", "b")])
5482 .text("First")
5483 .close("option")
5484 .open("option", &[("value", "b")])
5485 .text("Second")
5486 .close("option")
5487 .close("select");
5488 assert_eq!(
5489 page.render(),
5490 format!(
5491 "<select>{}{}</select>",
5492 option("b", "First", true),
5493 option("b", "Second", false)
5494 )
5495 );
5496 }
5497
5498 #[test]
5499 fn a_multiple_select_selects_every_option_in_an_array_value() {
5500 let render = |attrs: &[(&str, &str)]| {
5504 let mut page = Page::new();
5505 let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
5506 page.open("select", attrs).dyn_attr("value", v);
5507 for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
5508 page.open("option", &[("value", value)])
5509 .text(label)
5510 .close("option");
5511 }
5512 page.close("select");
5513 page.render()
5514 };
5515 assert_eq!(
5516 render(&[("multiple", "")]),
5517 format!(
5518 "<select multiple>{}{}{}</select>",
5519 option("a", "Apple", true),
5520 option("b", "Banana", false),
5521 option("c", "Cherry", true)
5522 )
5523 );
5524 assert_eq!(
5525 render(&[]),
5526 format!(
5527 "<select>{}{}{}</select>",
5528 option("a", "Apple", false),
5529 option("b", "Banana", false),
5530 option("c", "Cherry", false)
5531 )
5532 );
5533 }
5534
5535 #[test]
5536 fn a_bound_select_overrides_authored_selected_on_its_options() {
5537 let render = |bound: bool| {
5540 let mut page = Page::new();
5541 let v = page.slot(text("a"));
5542 let on = page.slot(SlotValue::Bool(true));
5543 page.open("select", &[]);
5544 if bound {
5545 page.dyn_attr("value", v);
5546 }
5547 page.open("option", &[("value", "a")])
5548 .text("A")
5549 .close("option")
5550 .open("option", &[("value", "b"), ("selected", "")])
5551 .text("B")
5552 .close("option")
5553 .open("option", &[("value", "c")])
5554 .dyn_attr("selected", on)
5555 .text("C")
5556 .close("option")
5557 .close("select");
5558 page.render()
5559 };
5560 assert_eq!(
5561 render(true),
5562 format!(
5563 "<select>{}{}{}</select>",
5564 option("a", "A", true),
5565 option("b", "B", false),
5566 option("c", "C", false)
5567 )
5568 );
5569 assert_eq!(
5570 render(false),
5571 format!(
5572 "<select>{}{}{}</select>",
5573 option("a", "A", false),
5574 option("b", "B", true),
5575 option("c", "C", true)
5576 )
5577 );
5578 }
5579
5580 #[test]
5581 fn a_static_select_value_attribute_selects_its_option() {
5582 let mut page = Page::new();
5583 page.open("select", &[("value", "b")])
5584 .open("option", &[("value", "a")])
5585 .text("A")
5586 .close("option")
5587 .open("option", &[("value", "b")])
5588 .text("B")
5589 .close("option")
5590 .close("select");
5591 assert_eq!(
5592 page.render(),
5593 format!(
5594 "<select>{}{}</select>",
5595 option("a", "A", false),
5596 option("b", "B", true)
5597 )
5598 );
5599 }
5600
5601 #[test]
5602 fn the_context_rules_hold_in_an_island_fragment() {
5603 let mut page = Page::new();
5606 let body = page.slot(text("hello"));
5607 let choice = page.slot(text("b"));
5608 page.island(0, |p| {
5609 p.open("form", &[])
5610 .open("textarea", &[])
5611 .dyn_text(body)
5612 .close("textarea")
5613 .open("select", &[])
5614 .dyn_attr("value", choice)
5615 .open("option", &[("value", "a")])
5616 .text("A")
5617 .close("option")
5618 .open("option", &[("value", "b")])
5619 .text("B")
5620 .close("option")
5621 .close("select")
5622 .close("form");
5623 });
5624 let inner = format!(
5625 "<textarea>hello</textarea><select>{}{}</select>",
5626 option("a", "A", false),
5627 option("b", "B", true)
5628 );
5629 let full = page.render();
5630 let fragment = page.render_island(0);
5631 for (path, html) in [("walk_to_html", &full), ("walk_island", &fragment)] {
5632 assert!(html.contains(&inner), "{path}: {html}");
5633 }
5634 assert!(
5635 full.starts_with("<!--f:i0--><form data-forma-island=\"0\""),
5636 "{full}"
5637 );
5638 }
5639
5640 fn branding_module() -> IrModule {
5647 let mut row = encode_open_tag(2, &[]);
5648 row.extend_from_slice(&encode_dyn_text(2, 1));
5649 row.extend_from_slice(&encode_close_tag(2));
5650 let mut ops = encode_island_start(0);
5651 ops.extend_from_slice(&encode_open_tag(0, &[]));
5652 ops.extend_from_slice(&encode_dyn_text(0, 0));
5653 ops.extend_from_slice(&encode_list(1, 2, &row));
5654 ops.extend_from_slice(&encode_close_tag(0));
5655 ops.extend_from_slice(&encode_island_end(0));
5656 let data = build_minimal_ir(
5657 &["div", "Branding", "i", "name", "colors", "color"],
5658 &[
5659 (0, 3, 0x01, 0x00, &[]),
5660 (1, 4, 0x04, 0x00, &[]),
5661 (2, 5, 0x01, 0x00, &[]),
5662 ],
5663 &ops,
5664 &[(0, 0x01, 0x01, 1, 0, &[])],
5665 );
5666 IrModule::parse(&data).unwrap()
5667 }
5668
5669 fn branding(name: &str, colors: usize) -> SlotData {
5670 let mut slots = SlotData::new(3);
5671 slots.set(0, SlotValue::Text(name.to_string()));
5672 slots.set(
5673 1,
5674 SlotValue::array(
5675 (0..colors)
5676 .map(|i| SlotValue::Text(format!("c{i}")))
5677 .collect(),
5678 ),
5679 );
5680 slots
5681 }
5682
5683 #[test]
5684 fn counted_walk_writes_what_walk_to_html_writes_and_counts_it() {
5685 let module = branding_module();
5686 let slots = branding("Acme", 3);
5687 let out = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap();
5688
5689 assert_eq!(out.html, walk_to_html(&module, &slots).unwrap());
5690 assert_eq!(out.counts.bytes, out.html.len());
5691 assert_eq!(out.counts.nodes, 8);
5694 assert_eq!(out.counts.list_iterations, 3);
5695 }
5696
5697 #[test]
5698 fn counted_walk_returns_the_counts_reached_when_a_budget_stops_it() {
5699 let module = branding_module();
5700
5701 let limits = WalkLimits {
5704 max_list_iterations: 5,
5705 ..WalkLimits::default()
5706 };
5707 let failure = walk_to_html_counted(&module, &branding("Acme", 9), limits).unwrap_err();
5708 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 5 });
5709 assert_eq!(failure.counts.list_iterations, 0);
5710 assert_eq!(failure.counts.nodes, 2);
5711 assert_eq!(failure.component_path, vec!["Branding".to_string()]);
5712 assert!(failure.counts.bytes > 0);
5713
5714 let limits = WalkLimits {
5717 max_output_bytes: 400,
5718 ..WalkLimits::default()
5719 };
5720 let failure = walk_to_html_counted(&module, &branding("Acme", 50), limits).unwrap_err();
5721 assert_eq!(failure.error, IrError::OutputLimitExceeded { limit: 400 });
5722 let counts = failure.counts;
5723 assert!(counts.bytes <= 400 && counts.bytes > 300, "{counts:?}");
5724 assert!(
5725 counts.list_iterations > 1 && counts.list_iterations < 50,
5726 "{counts:?}"
5727 );
5728 assert!(
5729 counts.nodes >= 2 + 2 * (counts.list_iterations - 1),
5730 "{counts:?}"
5731 );
5732 assert_eq!(failure.component_path, vec!["Branding".to_string()]);
5733 }
5734
5735 #[test]
5736 fn counted_walk_names_no_component_for_a_failure_outside_every_island() {
5737 let mut ops = encode_open_tag(0, &[]);
5739 ops.extend_from_slice(&encode_open_tag(1, &[]));
5740 let data = build_minimal_ir(&["p", "bad name"], &[], &ops, &[]);
5741 let module = IrModule::parse(&data).unwrap();
5742 let failure =
5743 walk_to_html_counted(&module, &SlotData::new(0), WalkLimits::default()).unwrap_err();
5744 assert_eq!(failure.error, IrError::InvalidName("bad name".into()));
5745 assert!(failure.component_path.is_empty());
5746 assert_eq!(failure.counts.nodes, 1);
5747 }
5748
5749 #[test]
5750 fn counted_walk_names_every_island_around_a_failure_outermost_first() {
5751 let mut ops = encode_island_start(0); ops.extend_from_slice(&encode_open_tag(0, &[]));
5756 let done_at = ops.len() as u32;
5757 ops.extend_from_slice(&encode_island_start(2)); ops.extend_from_slice(&encode_open_tag(0, &[]));
5759 ops.extend_from_slice(&encode_close_tag(0));
5760 ops.extend_from_slice(&encode_island_end(2));
5761 let card_at = ops.len() as u32;
5762 ops.extend_from_slice(&encode_island_start(1)); ops.extend_from_slice(&encode_open_tag(0, &[]));
5764 ops.extend_from_slice(&encode_open_tag(4, &[])); ops.extend_from_slice(&encode_close_tag(0));
5766 ops.extend_from_slice(&encode_island_end(1));
5767 ops.extend_from_slice(&encode_close_tag(0));
5768 ops.extend_from_slice(&encode_island_end(0));
5769 let data = build_minimal_ir(
5770 &["div", "Shell", "Card", "Done", "bad name"],
5771 &[],
5772 &ops,
5773 &[
5774 (0, 0x01, 0x01, 1, 0, &[]),
5775 (1, 0x01, 0x01, 2, card_at, &[]),
5776 (2, 0x01, 0x01, 3, done_at, &[]),
5777 ],
5778 );
5779 let module = IrModule::parse(&data).unwrap();
5780 let failure =
5781 walk_to_html_counted(&module, &SlotData::new(0), WalkLimits::default()).unwrap_err();
5782 assert_eq!(
5783 failure.component_path,
5784 vec!["Shell".to_string(), "Card".to_string()]
5785 );
5786 }
5787
5788 #[test]
5789 fn the_fault_trigger_is_armed_only_by_the_counted_walk() {
5790 let module = branding_module();
5794 let slots = branding(crate::fault_injection::FAULT_TRIGGER, 1);
5795 let html = walk_to_html(&module, &slots).expect("walk_to_html never arms it");
5796 assert!(html.contains("forma-test-fault"), "{html}");
5797 let failure = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap_err();
5798 assert!(matches!(failure.error, IrError::Panicked { .. }));
5799 }
5800
5801 #[test]
5802 fn counted_walk_catches_a_panic_and_names_its_island() {
5803 let module = branding_module();
5807 let slots = branding(crate::fault_injection::FAULT_TRIGGER, 2);
5808 let failure = walk_to_html_counted(&module, &slots, WalkLimits::default()).unwrap_err();
5809 match &failure.error {
5810 IrError::Panicked { message } => {
5811 assert!(message.contains("stand-in"), "{message}")
5812 }
5813 other => panic!("expected Panicked, got {other:?}"),
5814 }
5815 assert_eq!(failure.component_path, vec!["Branding".to_string()]);
5816 let out =
5818 walk_to_html_counted(&module, &branding("Acme", 2), WalkLimits::default()).unwrap();
5819 assert!(out.html.contains("Acme"));
5820 }
5821
5822 fn lists_module(lists: usize, nested: bool) -> IrModule {
5828 let mut ops = encode_open_tag(0, &[]);
5829 let mut slots: Vec<(u16, u32, u8, u8, &[u8])> = Vec::new();
5830 for k in 0..lists as u16 {
5831 let (list_slot, item_slot) = (2 * k, 2 * k + 1);
5832 let mut row = encode_open_tag(1, &[]);
5833 if nested {
5834 let inner_item = 2 * lists as u16;
5837 let mut inner_row = encode_open_tag(1, &[]);
5838 inner_row.extend_from_slice(&encode_close_tag(1));
5839 row.extend_from_slice(&encode_list(item_slot, inner_item, &inner_row));
5840 }
5841 row.extend_from_slice(&encode_close_tag(1));
5842 ops.extend_from_slice(&encode_list(list_slot, item_slot, &row));
5843 slots.push((list_slot, 2, 0x04, 0x00, &[]));
5844 slots.push((item_slot, 3, if nested { 0x04 } else { 0x01 }, 0x00, &[]));
5845 }
5846 if nested {
5847 slots.push((2 * lists as u16, 3, 0x01, 0x00, &[]));
5848 }
5849 ops.extend_from_slice(&encode_close_tag(0));
5850 let data = build_minimal_ir(&["div", "i", "items", "item"], &slots, &ops, &[]);
5851 IrModule::parse(&data).unwrap()
5852 }
5853
5854 fn rows(n: usize) -> SlotValue {
5855 SlotValue::array((0..n).map(|_| SlotValue::Text("x".into())).collect())
5856 }
5857
5858 fn limits(max_list_iterations: usize) -> WalkLimits {
5859 WalkLimits {
5860 max_list_iterations,
5861 ..WalkLimits::default()
5862 }
5863 }
5864
5865 #[test]
5866 fn a_list_longer_than_the_remaining_budget_fails_before_its_first_row() {
5867 let module = lists_module(1, false);
5869 let mut slots = SlotData::new(2);
5870 slots.set(0, rows(51));
5871 let failure = walk_to_html_counted(&module, &slots, limits(50)).unwrap_err();
5872 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 50 });
5873 assert_eq!(failure.counts.list_iterations, 0);
5874 assert_eq!(failure.counts.nodes, 1, "only the wrapping div was written");
5875
5876 slots.set(0, rows(50));
5878 let out = walk_to_html_counted(&module, &slots, limits(50)).unwrap();
5879 assert_eq!(out.counts.list_iterations, 50);
5880 assert_eq!(out.html.matches("<i>").count(), 50);
5881 }
5882
5883 #[test]
5884 fn a_list_exactly_at_the_remaining_budget_still_renders() {
5885 let module = lists_module(2, false);
5888 let mut slots = SlotData::new(4);
5889 slots.set(0, rows(20));
5890 slots.set(2, rows(30));
5891 let out = walk_to_html_counted(&module, &slots, limits(50)).unwrap();
5892 assert_eq!(out.counts.list_iterations, 50);
5893 assert_eq!(out.html.matches("<i>").count(), 50);
5894
5895 slots.set(2, rows(31));
5896 let failure = walk_to_html_counted(&module, &slots, limits(50)).unwrap_err();
5897 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 50 });
5898 assert_eq!(
5899 failure.counts.list_iterations, 20,
5900 "the first list, and no more"
5901 );
5902 assert_eq!(failure.counts.nodes, 1 + 20);
5903 }
5904
5905 #[test]
5906 fn a_nested_list_is_checked_against_what_its_parents_left() {
5907 let module = lists_module(1, true);
5912 let mut slots = SlotData::new(3);
5913 slots.set(0, SlotValue::array((0..3).map(|_| rows(20)).collect()));
5914 let failure = walk_to_html_counted(&module, &slots, limits(59)).unwrap_err();
5915 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 59 });
5916 assert_eq!(failure.counts.list_iterations, 43);
5917
5918 let out = walk_to_html_counted(&module, &slots, limits(63)).unwrap();
5920 assert_eq!(out.counts.list_iterations, 63);
5921 assert!(walk_to_html_counted(&module, &slots, limits(62)).is_err());
5922 }
5923
5924 #[test]
5925 fn the_per_row_check_still_stops_a_list_whose_nested_rows_spend_the_budget() {
5926 let module = lists_module(1, true);
5930 let mut slots = SlotData::new(3);
5931 slots.set(0, SlotValue::array((0..3).map(|_| rows(11)).collect()));
5932 let failure = walk_to_html_counted(&module, &slots, limits(24)).unwrap_err();
5933 assert_eq!(failure.error, IrError::ListBudgetExceeded { max: 24 });
5934 assert_eq!(failure.counts.list_iterations, 24);
5935 }
5936}