1#[cfg(feature = "tracing")]
8use crate::format::SlotSource;
9use crate::format::{IrError, IslandTrigger, Opcode, PropsMode};
10use crate::parser::IrModule;
11use crate::slot::{SlotData, SlotValue};
12
13struct IslandPending {
19 id: u16,
20 component_name: String,
21 inline_props: Option<String>,
22 trigger: IslandTrigger,
23}
24
25pub const INLINE_PROPS_MAX_BYTES: usize = 1024;
47
48pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
57
58pub const DEFAULT_MAX_LIST_ITERATIONS: usize = 50_000;
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct WalkLimits {
70 pub max_output_bytes: usize,
72 pub max_list_iterations: usize,
74}
75
76impl Default for WalkLimits {
77 fn default() -> Self {
78 WalkLimits {
79 max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
80 max_list_iterations: DEFAULT_MAX_LIST_ITERATIONS,
81 }
82 }
83}
84
85struct WalkState {
90 list_depth: u8,
92 pending_island: Option<IslandPending>,
94 pending_list_key: Option<String>,
96 script_tag_props: std::collections::BTreeMap<u16, serde_json::Value>,
98 pending_tag_close: bool,
101 pending_tag_name: String,
104 max_output_bytes: usize,
108 list_iterations: usize,
111 max_list_iterations: usize,
113 slot_journal: Vec<(u16, SlotValue)>,
127 force_inline_props: bool,
132 text_only: Option<TextOnlyParent>,
136 pending_control: PendingControl,
139 select: Option<SelectBinding>,
141 option_by_text: Option<OptionByText>,
144}
145
146impl WalkState {
147 fn new(limits: WalkLimits) -> Self {
148 WalkState {
149 list_depth: 0,
150 pending_island: None,
151 pending_list_key: None,
152 script_tag_props: std::collections::BTreeMap::new(),
153 pending_tag_close: false,
154 pending_tag_name: String::new(),
155 max_output_bytes: limits.max_output_bytes,
156 list_iterations: 0,
157 max_list_iterations: limits.max_list_iterations,
158 slot_journal: Vec::new(),
159 force_inline_props: false,
160 text_only: None,
161 pending_control: PendingControl::None,
162 select: None,
163 option_by_text: None,
164 }
165 }
166
167 fn journal_write(&mut self, slot_id: u16, previous: SlotValue) {
173 if self.list_depth > 0 {
174 self.slot_journal.push((slot_id, previous));
175 }
176 }
177
178 fn unwind_slot_journal(&mut self, slots: &mut SlotData, mark: usize) {
180 while self.slot_journal.len() > mark {
181 if let Some((slot_id, previous)) = self.slot_journal.pop() {
183 slots.set(slot_id, previous);
184 }
185 }
186 }
187}
188
189#[cfg_attr(not(feature = "tracing"), allow(unused_variables))]
200fn check_slot_source(module: &IrModule, slot_id: u16, value: &SlotValue) {
201 #[cfg(feature = "tracing")]
205 if let Some(entry) = module.slot_entry(slot_id) {
206 if entry.source == SlotSource::Server && matches!(value, SlotValue::Null) {
207 tracing::warn!(
208 slot_id = slot_id,
209 "Server-sourced slot has no value at render time — handler may have a bug"
210 );
211 }
212 }
213}
214
215pub fn walk_to_html(module: &IrModule, slots: &SlotData) -> Result<String, IrError> {
225 walk_to_html_with_limits(module, slots, WalkLimits::default())
226}
227
228pub fn walk_to_html_with_limits(
235 module: &IrModule,
236 slots: &SlotData,
237 limits: WalkLimits,
238) -> Result<String, IrError> {
239 let ops = &module.opcodes;
240 let len = ops.len();
241 let mut out = String::with_capacity(len.saturating_mul(4).min(1 << 20));
247 let mut state = WalkState::new(limits);
248 let mut slots_mut = slots.clone();
249 walk_range(module, &mut slots_mut, ops, 0, len, &mut out, &mut state, 0)?;
250
251 if !state.script_tag_props.is_empty() {
253 let json =
254 serde_json::to_string(&state.script_tag_props).unwrap_or_else(|_| "{}".to_string());
255 push_checked(
256 &mut out,
257 "<script id=\"__forma_islands\" type=\"application/json\">",
258 state.max_output_bytes,
259 )?;
260 push_json_script_body(&mut out, &json, state.max_output_bytes)?;
261 push_checked(&mut out, "</script>", state.max_output_bytes)?;
262 }
263
264 Ok(out)
265}
266
267fn push_json_script_body(out: &mut String, json: &str, limit: usize) -> Result<(), IrError> {
288 for ch in json.chars() {
289 match ch {
290 '<' => push_checked(out, "\\u003c", limit)?,
291 '>' => push_checked(out, "\\u003e", limit)?,
292 '\u{2028}' => push_checked(out, "\\u2028", limit)?,
293 '\u{2029}' => push_checked(out, "\\u2029", limit)?,
294 _ => {
295 if out.len() >= limit {
296 return Err(IrError::OutputLimitExceeded { limit });
297 }
298 out.push(ch);
299 }
300 }
301 }
302 Ok(())
303}
304
305pub fn walk_island(module: &IrModule, slots: &SlotData, island_id: u16) -> Result<String, IrError> {
329 walk_island_with_limits(module, slots, island_id, WalkLimits::default())
330}
331
332pub fn walk_island_with_limits(
334 module: &IrModule,
335 slots: &SlotData,
336 island_id: u16,
337 limits: WalkLimits,
338) -> Result<String, IrError> {
339 let entry = module
341 .island_entry(island_id)
342 .ok_or(IrError::IslandNotFound(island_id))?;
343
344 let byte_offset = entry.byte_offset as usize;
345 let ops = &module.opcodes;
346
347 if byte_offset >= ops.len() {
350 return Err(IrError::BufferTooShort {
351 expected: byte_offset.saturating_add(1),
352 actual: ops.len(),
353 });
354 }
355
356 let opcode = Opcode::from_byte(ops[byte_offset])?;
358 if opcode != Opcode::IslandStart {
359 return Err(IrError::InvalidOpcode(ops[byte_offset]));
360 }
361
362 let content_start = byte_offset.saturating_add(3);
364 if content_start > ops.len() {
365 return Err(IrError::BufferTooShort {
366 expected: content_start,
367 actual: ops.len(),
368 });
369 }
370
371 let mut out = String::with_capacity(256);
375 let mut state = WalkState::new(limits);
376 state.force_inline_props = true;
379 let mut slots_mut = slots.clone();
380
381 let component_name = module.strings.get(entry.name_str_idx)?.to_string();
382 let inline_props = match entry.props_mode {
383 PropsMode::Inline | PropsMode::ScriptTag => {
386 if entry.slot_ids.is_empty() {
387 None
388 } else {
389 let props = build_island_props(module, &slots_mut, &entry.slot_ids);
390 Some(serde_json::to_string(&props).unwrap_or_else(|_| "{}".to_string()))
391 }
392 }
393 PropsMode::Deferred => None,
394 };
395 state.pending_island = Some(IslandPending {
396 id: island_id,
397 component_name,
398 inline_props,
399 trigger: entry.trigger,
400 });
401
402 walk_range_until_island_end(
404 module,
405 &mut slots_mut,
406 ops,
407 content_start,
408 island_id,
409 &mut out,
410 &mut state,
411 0,
412 )?;
413
414 Ok(out)
415}
416
417const MAX_LIST_DEPTH: u8 = 4;
424
425const MAX_RECURSION_DEPTH: usize = 64;
428
429enum WalkMode {
431 Range { end: usize },
435 UntilIslandEnd { target_island_id: u16 },
438}
439
440#[allow(clippy::too_many_arguments)]
447fn walk_range(
448 module: &IrModule,
449 slots: &mut SlotData,
450 ops: &[u8],
451 start: usize,
452 end: usize,
453 out: &mut String,
454 state: &mut WalkState,
455 depth: usize,
456) -> Result<(), IrError> {
457 walk_range_impl(
458 module,
459 slots,
460 ops,
461 start,
462 WalkMode::Range { end },
463 out,
464 state,
465 depth,
466 )
467}
468
469#[allow(clippy::too_many_arguments)]
476fn walk_range_until_island_end(
477 module: &IrModule,
478 slots: &mut SlotData,
479 ops: &[u8],
480 start: usize,
481 target_island_id: u16,
482 out: &mut String,
483 state: &mut WalkState,
484 depth: usize,
485) -> Result<(), IrError> {
486 walk_range_impl(
487 module,
488 slots,
489 ops,
490 start,
491 WalkMode::UntilIslandEnd { target_island_id },
492 out,
493 state,
494 depth,
495 )
496}
497
498#[allow(clippy::too_many_arguments)]
513fn walk_range_impl(
514 module: &IrModule,
515 slots: &mut SlotData,
516 ops: &[u8],
517 start: usize,
518 mode: WalkMode,
519 out: &mut String,
520 state: &mut WalkState,
521 depth: usize,
522) -> Result<(), IrError> {
523 if depth > MAX_RECURSION_DEPTH {
524 return Err(IrError::RecursionLimitExceeded);
525 }
526
527 let strings = &module.strings;
528 let mut pos = start;
529 let limit = state.max_output_bytes;
530 let mut fallback_stack: Vec<usize> = Vec::new();
538
539 let loop_bound = match mode {
543 WalkMode::Range { end } => end,
544 WalkMode::UntilIslandEnd { .. } => usize::MAX, };
546
547 let region_limit = match mode {
551 WalkMode::Range { end } => end.min(ops.len()),
552 WalkMode::UntilIslandEnd { .. } => ops.len(),
553 };
554
555 while pos < loop_bound {
556 if out.len() > limit {
559 return Err(IrError::OutputLimitExceeded { limit });
560 }
561
562 if pos >= ops.len() {
565 match mode {
566 WalkMode::Range { .. } => {
567 return Err(IrError::BufferTooShort {
568 expected: pos + 1,
569 actual: ops.len(),
570 });
571 }
572 WalkMode::UntilIslandEnd { .. } => break, }
574 }
575
576 let opcode = Opcode::from_byte(ops[pos])?;
577 pos += 1; if state.pending_tag_close && opcode != Opcode::DynAttr {
582 close_start_tag(out, state, limit)?;
583 }
584
585 match opcode {
586 Opcode::OpenTag | Opcode::VoidTag => {
588 let (tag_str_idx, attrs, new_pos) = read_tag_with_attrs(ops, pos, strings)?;
589 let tag = strings.get(tag_str_idx)?;
590 let has_content = opcode == Opcode::OpenTag;
591 emit_open_tag(out, tag, &attrs, has_content, state, limit)?;
592 pos = new_pos;
593 }
594
595 Opcode::CloseTag => {
596 let str_idx = read_u32(ops, pos)?;
597 pos += 4;
598 let tag = strings.get(str_idx)?;
599 if !is_valid_name(tag) {
602 return Err(IrError::InvalidName(tag.to_string()));
603 }
604 leave_element(out, tag, state, limit)?;
605 push_checked(out, "</", limit)?;
606 push_checked(out, tag, limit)?;
607 push_checked(out, ">", limit)?;
608 }
609
610 Opcode::Text => {
611 let str_idx = read_u32(ops, pos)?;
612 pos += 4;
613 let text = strings.get(str_idx)?;
614 if let Some(parent) = state.text_only.as_mut() {
615 if !parent.value_bound {
616 write_text_only(out, parent, text, limit)?;
617 }
618 } else {
619 push_escaped_html(out, text, limit)?;
620 if let Some(option) = state.option_by_text.as_mut() {
621 option.text.push_str(text);
622 }
623 }
624 }
625
626 Opcode::DynText => {
627 let slot_id = read_u16(ops, pos)?;
629 pos += 2;
630 let marker_id = read_u16(ops, pos)?;
631 pos += 2;
632
633 check_slot_source(module, slot_id, slots.get(slot_id));
634 let value = slots.get(slot_id).to_text();
635
636 if let Some(parent) = state.text_only.as_mut() {
637 if !parent.value_bound {
640 write_text_only(out, parent, &value, limit)?;
641 }
642 } else {
643 push_checked(out, "<!--f:t", limit)?;
644 push_u16(out, marker_id, limit)?;
645 push_checked(out, "-->", limit)?;
646 let written = if value.is_empty() {
652 "\u{200B}"
653 } else {
654 value.as_str()
655 };
656 push_escaped_html(out, written, limit)?;
657 push_checked(out, "<!--/f:t", limit)?;
658 push_u16(out, marker_id, limit)?;
659 push_checked(out, "-->", limit)?;
660 if let Some(option) = state.option_by_text.as_mut() {
661 option.text.push_str(written);
662 }
663 }
664 }
665
666 Opcode::DynAttr => {
667 let attr_str_idx = read_u32(ops, pos)?;
669 let slot_id = read_u16(ops, pos + 4)?;
670 pos += 6;
671 check_slot_source(module, slot_id, slots.get(slot_id));
672
673 if state.pending_tag_close {
674 let attr_name = strings.get(attr_str_idx)?;
675 if consume_control_attr(state, attr_name, Prop::Slot(slots.get(slot_id))) {
678 continue;
679 }
680 let text;
683 let value = match slots.get(slot_id) {
684 SlotValue::Bool(false) | SlotValue::Null => None,
690 SlotValue::Bool(true) => Some(AttrValue::Bare),
691 other => {
692 text = other.to_text();
693 Some(AttrValue::Valued(text.as_str()))
698 }
699 };
700 if let Some(value) = value {
701 let tag = std::mem::take(&mut state.pending_tag_name);
705 emit_attr(out, &tag, attr_name, value, limit)?;
706 state.pending_tag_name = tag;
707 }
708 }
709 }
711
712 Opcode::IslandStart => {
713 let island_id = read_u16(ops, pos)?;
715 pos += 2;
716
717 if let Some(outer) = &state.pending_island {
724 return Err(IrError::NestedIslandRoot {
725 outer: outer.id,
726 inner: island_id,
727 });
728 }
729
730 if state.text_only.is_none() {
732 push_checked(out, "<!--f:i", limit)?;
733 push_u16(out, island_id, limit)?;
734 push_checked(out, "-->", limit)?;
735 }
736
737 if let Some(entry) = module.island_entry(island_id) {
743 let component_name = strings.get(entry.name_str_idx)?.to_string();
744
745 let inline_props = match entry.props_mode {
747 PropsMode::Inline => {
748 if entry.slot_ids.is_empty() {
749 None
750 } else {
751 let props = build_island_props(module, slots, &entry.slot_ids);
752 let json = serde_json::to_string(&props)
753 .unwrap_or_else(|_| "{}".to_string());
754 if json.len() > INLINE_PROPS_MAX_BYTES && !state.force_inline_props
755 {
756 state
762 .script_tag_props
763 .insert(island_id, serde_json::Value::Object(props));
764 None
765 } else {
766 Some(json)
767 }
768 }
769 }
770 PropsMode::ScriptTag if state.force_inline_props => {
771 if entry.slot_ids.is_empty() {
775 None
776 } else {
777 let props = build_island_props(module, slots, &entry.slot_ids);
778 Some(
779 serde_json::to_string(&props)
780 .unwrap_or_else(|_| "{}".to_string()),
781 )
782 }
783 }
784 PropsMode::ScriptTag => {
785 if !entry.slot_ids.is_empty() {
787 let props = build_island_props(module, slots, &entry.slot_ids);
788 state
789 .script_tag_props
790 .insert(island_id, serde_json::Value::Object(props));
791 }
792 None
793 }
794 PropsMode::Deferred => None,
795 };
796
797 state.pending_island = Some(IslandPending {
798 id: island_id,
799 component_name,
800 inline_props,
801 trigger: entry.trigger,
802 });
803 } else {
804 #[cfg(feature = "tracing")]
805 tracing::warn!(
806 island_id = island_id,
807 "ISLAND_START names an island that is not in the island table — \
808 markers emitted but no data-forma-* attributes, so it will not hydrate \
809 (module was not validated)"
810 );
811 }
812 }
813
814 Opcode::IslandEnd => {
815 let island_id = read_u16(ops, pos)?;
817 pos += 2;
818
819 if state.pending_island.take().is_some() {
825 #[cfg(feature = "tracing")]
826 tracing::warn!(
827 island_id = island_id,
828 "ISLAND_START was not followed by an element — island attributes dropped, \
829 this island will not hydrate (compiler should emit a shell element)"
830 );
831 }
832
833 if let WalkMode::UntilIslandEnd { target_island_id } = mode {
835 if island_id == target_island_id {
836 if state.pending_tag_close {
838 close_start_tag(out, state, limit)?;
839 }
840 return Ok(());
841 }
842 }
843
844 if state.text_only.is_none() {
848 push_checked(out, "<!--/f:i", limit)?;
849 push_u16(out, island_id, limit)?;
850 push_checked(out, "-->", limit)?;
851 }
852 }
853
854 Opcode::Comment => {
855 let str_idx = read_u32(ops, pos)?;
857 pos += 4;
858 let text = strings.get(str_idx)?;
859 push_checked(out, "<!--", limit)?;
860 if text.starts_with('>') || text.starts_with("->") {
866 push_checked(out, " ", limit)?;
867 }
868 push_checked(out, &text.replace("--", "--"), limit)?;
869 push_checked(out, "-->", limit)?;
870 }
871
872 Opcode::ShowIf => {
874 let slot_id = read_u16(ops, pos)?;
876 let then_len = read_u32(ops, pos + 2)? as usize;
877 let else_len = read_u32(ops, pos + 6)? as usize;
878 pos += 10;
879
880 check_slot_source(module, slot_id, slots.get(slot_id));
881 let condition = slots.get(slot_id).as_bool();
882 let then_start = pos;
883 let then_end = bounded_offset(pos, then_len, region_limit)?;
887 if then_end >= region_limit || ops[then_end] != Opcode::ShowElse as u8 {
891 return Err(IrError::InvalidSection);
892 }
893 let else_start = then_end + 1; let else_end = bounded_offset(else_start, else_len, region_limit)?;
895
896 let markers = state.text_only.is_none();
899 if markers {
900 push_checked(out, "<!--f:s", limit)?;
901 push_u16(out, slot_id, limit)?;
902 push_checked(out, "-->", limit)?;
903 }
904
905 if condition {
906 walk_range(
907 module,
908 slots,
909 ops,
910 then_start,
911 then_end,
912 out,
913 state,
914 depth + 1,
915 )?;
916 } else {
917 walk_range(
918 module,
919 slots,
920 ops,
921 else_start,
922 else_end,
923 out,
924 state,
925 depth + 1,
926 )?;
927 }
928
929 if markers {
930 push_checked(out, "<!--/f:s", limit)?;
931 push_u16(out, slot_id, limit)?;
932 push_checked(out, "-->", limit)?;
933 }
934
935 pos = else_end;
937 }
938
939 Opcode::ShowElse => {
940 }
944
945 Opcode::Switch => {
956 let slot_id = read_u16(ops, pos)?;
957 let case_count = read_u16(ops, pos + 2)? as usize;
958 pos += 4;
959
960 let mut cases =
964 Vec::with_capacity(case_count.min(ops.len().saturating_sub(pos) / 8));
965 for _ in 0..case_count {
966 let val_str_idx = read_u32(ops, pos)?;
967 let body_len = read_u32(ops, pos + 4)? as usize;
968 cases.push((val_str_idx, body_len));
969 pos += 8;
970 }
971
972 check_slot_source(module, slot_id, slots.get(slot_id));
974 let slot_text = slots.get(slot_id).to_text();
975 let mut body_pos = pos;
976 let mut matched = false;
980
981 for (val_str_idx, body_len) in &cases {
982 let case_end = bounded_offset(body_pos, *body_len, region_limit)?;
986 let case_val = strings.get(*val_str_idx)?;
987 if !matched && case_val == slot_text {
988 matched = true;
989 walk_range(
990 module,
991 slots,
992 ops,
993 body_pos,
994 case_end,
995 out,
996 state,
997 depth + 1,
998 )?;
999 }
1000 body_pos = case_end;
1001 }
1002
1003 pos = body_pos; }
1005
1006 Opcode::List => {
1007 let slot_id = read_u16(ops, pos)?;
1009 let item_slot_id = read_u16(ops, pos + 2)?;
1010 let body_len = read_u32(ops, pos + 4)? as usize;
1011 pos += 8;
1012
1013 check_slot_source(module, slot_id, slots.get(slot_id));
1014
1015 let body_start = pos;
1016 let body_end = bounded_offset(pos, body_len, region_limit)?;
1017
1018 let list_marker_id = slot_id;
1021 let markers = state.text_only.is_none();
1022 if markers {
1023 push_checked(out, "<!--f:l", limit)?;
1024 push_u16(out, list_marker_id, limit)?;
1025 push_checked(out, "-->", limit)?;
1026 }
1027
1028 let items = match slots.get(slot_id) {
1034 SlotValue::Array(a) => Some(a.clone()),
1035 _ => None,
1036 };
1037
1038 if let Some(items) = items.filter(|a| !a.is_empty()) {
1039 state.list_depth += 1;
1040 if state.list_depth > MAX_LIST_DEPTH {
1041 return Err(IrError::ListDepthExceeded {
1042 max: MAX_LIST_DEPTH,
1043 });
1044 }
1045 for item in items.iter() {
1046 state.list_iterations += 1;
1050 if state.list_iterations > state.max_list_iterations {
1051 return Err(IrError::ListBudgetExceeded {
1052 max: state.max_list_iterations,
1053 });
1054 }
1055 let mark = state.slot_journal.len();
1060 let previous = slots.replace(item_slot_id, item.clone());
1061 state.slot_journal.push((item_slot_id, previous));
1062
1063 let walked = walk_range(
1064 module,
1065 slots,
1066 ops,
1067 body_start,
1068 body_end,
1069 out,
1070 state,
1071 depth + 1,
1072 );
1073 state.unwind_slot_journal(slots, mark);
1077 walked?;
1078
1079 state.pending_list_key = None;
1084 }
1085 state.list_depth -= 1;
1086 }
1087 state.pending_list_key = None;
1088
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 pos = body_end;
1096 }
1097
1098 Opcode::TryStart => {
1099 let fallback_len = read_u32(ops, pos)? as usize;
1101 pos += 4;
1102 fallback_stack.push(fallback_len);
1105 }
1106
1107 Opcode::Fallback => {
1108 if let Some(skip) = fallback_stack.pop() {
1111 pos = bounded_offset(pos, skip, region_limit)?;
1112 }
1113 }
1114
1115 Opcode::Preload => {
1116 if pos >= ops.len() {
1117 return Err(IrError::BufferTooShort {
1118 expected: pos + 1,
1119 actual: ops.len(),
1120 });
1121 }
1122 let resource_type = ops[pos];
1123 let url_str_idx = read_u32(ops, pos + 1)?;
1124 let url = strings.get(url_str_idx)?;
1125 pos += 5;
1126
1127 let (as_val, type_attr) = match resource_type {
1128 1 => ("font", " type=\"font/woff2\" crossorigin"),
1129 2 => ("style", ""),
1130 3 => ("script", ""),
1131 4 => ("image", ""),
1132 _ => ("fetch", ""),
1133 };
1134 push_checked(out, "<link rel=\"preload\" href=\"", limit)?;
1142 push_escaped_attr(out, url, limit)?;
1143 push_checked(out, "\" as=\"", limit)?;
1144 push_checked(out, as_val, limit)?;
1145 push_checked(out, "\"", limit)?;
1146 push_checked(out, type_attr, limit)?;
1147 push_checked(out, ">\n", limit)?;
1148 }
1149
1150 Opcode::ListItemKey => {
1151 let key_str_idx = read_u32(ops, pos)?;
1153 pos += 4;
1154 let key_val = strings.get(key_str_idx)?;
1155 state.pending_list_key = Some(key_val.to_string());
1156 }
1157
1158 Opcode::Prop => {
1159 let src_slot_id = read_u16(ops, pos)?;
1161 let prop_str_idx = read_u32(ops, pos + 2)?;
1162 let target_slot_id = read_u16(ops, pos + 6)?;
1163 pos += 8;
1164
1165 let prop_name = strings.get(prop_str_idx)?;
1166 let value = slots.get(src_slot_id).get_property(prop_name);
1167 let previous = slots.replace(target_slot_id, value);
1172 state.journal_write(target_slot_id, previous);
1173 }
1174 }
1175 }
1176
1177 match mode {
1179 WalkMode::Range { .. } => {
1180 if state.pending_tag_close {
1190 close_start_tag(out, state, limit)?;
1191 }
1192 Ok(())
1193 }
1194 WalkMode::UntilIslandEnd { target_island_id } => {
1195 Err(IrError::IslandNotFound(target_island_id))
1198 }
1199 }
1200}
1201
1202fn build_island_props(
1218 module: &IrModule,
1219 slots: &SlotData,
1220 slot_ids: &[u16],
1221) -> serde_json::Map<String, serde_json::Value> {
1222 let mut map = serde_json::Map::new();
1223 for &slot_id in slot_ids {
1224 if let Some(entry) = module.slot_entry(slot_id) {
1226 if let Ok(name) = module.strings.get(entry.name_str_idx) {
1227 if map.contains_key(name) {
1228 #[cfg(feature = "tracing")]
1229 tracing::warn!(
1230 slot_id = slot_id,
1231 name = name,
1232 "Island declares two slots with the same name — keeping the first, \
1233 the later value is not reachable from the props object"
1234 );
1235 continue;
1236 }
1237 let value = slots.get(slot_id).to_json();
1238 map.insert(name.to_string(), value);
1239 }
1240 } else {
1241 #[cfg(feature = "tracing")]
1246 tracing::warn!(
1247 slot_id = slot_id,
1248 "Island references a slot id that is not in the slot table — prop dropped"
1249 );
1250 }
1251 }
1252 map
1253}
1254
1255fn bounded_offset(base: usize, len: usize, limit: usize) -> Result<usize, IrError> {
1265 base.checked_add(len)
1266 .filter(|e| *e <= limit)
1267 .ok_or(IrError::InvalidSection)
1268}
1269
1270fn is_valid_name(name: &str) -> bool {
1284 let mut chars = name.chars();
1285 match chars.next() {
1286 Some(c) if c.is_ascii_alphabetic() || c == '_' || c == ':' => {}
1287 _ => return false,
1288 }
1289 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'))
1290}
1291
1292fn is_event_handler_attr(name: &str) -> bool {
1294 name.len() >= 2 && name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
1295}
1296
1297const URL_ATTRS: [&str; 8] = [
1305 "href",
1306 "src",
1307 "action",
1308 "formaction",
1309 "xlink:href",
1310 "poster",
1311 "background",
1312 "data",
1313];
1314
1315const IMAGE_CONTEXT_TAGS: [&str; 13] = [
1319 "img", "image", "video", "audio", "source", "body", "table", "thead", "tbody", "tfoot", "tr",
1320 "td", "th",
1321];
1322
1323fn is_url_attr(name: &str) -> bool {
1325 URL_ATTRS.iter().any(|a| name.eq_ignore_ascii_case(a))
1326}
1327
1328fn is_dangerous_url(value: &str, tag: &str) -> bool {
1337 let normalized: String = value
1338 .chars()
1339 .filter(|c| {
1340 let n = *c as u32;
1341 !((n <= 0x20) || (0x7F..=0x9F).contains(&n))
1342 })
1343 .collect();
1344 let lower = normalized.to_ascii_lowercase();
1345
1346 if lower.starts_with("javascript") || lower.starts_with("vbscript") {
1347 return true;
1348 }
1349 if lower.starts_with("data:text/html") {
1350 return true;
1351 }
1352 if lower.starts_with("data:image/svg+xml") {
1357 return !IMAGE_CONTEXT_TAGS
1358 .iter()
1359 .any(|t| tag.eq_ignore_ascii_case(t));
1360 }
1361 false
1362}
1363
1364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1371enum AttrValue<'a> {
1372 Bare,
1374 Valued(&'a str),
1376}
1377
1378fn emit_attr(
1385 out: &mut String,
1386 tag: &str,
1387 key: &str,
1388 val: AttrValue<'_>,
1389 limit: usize,
1390) -> Result<(), IrError> {
1391 if !is_valid_name(key) {
1392 #[cfg(feature = "tracing")]
1396 tracing::warn!(attr = key, "Dropping attribute with an unsafe name");
1397 return Ok(());
1398 }
1399 if is_event_handler_attr(key) {
1400 #[cfg(feature = "tracing")]
1401 tracing::warn!(attr = key, "Dropping event-handler attribute from IR");
1402 return Ok(());
1403 }
1404 if let AttrValue::Valued(v) = val {
1405 if is_url_attr(key) && is_dangerous_url(v, tag) {
1406 #[cfg(feature = "tracing")]
1407 tracing::warn!(attr = key, "Dropping URL attribute with a dangerous scheme");
1408 return Ok(());
1409 }
1410 }
1411
1412 push_checked(out, " ", limit)?;
1413 push_checked(out, key, limit)?;
1414 if let AttrValue::Valued(v) = val {
1415 push_checked(out, "=\"", limit)?;
1416 push_escaped_attr(out, v, limit)?;
1417 push_checked(out, "\"", limit)?;
1418 }
1419 Ok(())
1420}
1421
1422fn emit_open_tag(
1428 out: &mut String,
1429 tag: &str,
1430 attrs: &[(&str, &str)],
1431 has_content: bool,
1432 state: &mut WalkState,
1433 limit: usize,
1434) -> Result<(), IrError> {
1435 if !is_valid_name(tag) {
1436 return Err(IrError::InvalidName(tag.to_string()));
1437 }
1438 if has_content {
1439 enter_element(out, tag, state, limit)?;
1440 }
1441 push_checked(out, "<", limit)?;
1442 push_checked(out, tag, limit)?;
1443
1444 for (key, val) in attrs {
1445 if consume_control_attr(state, key, Prop::Static(val)) {
1448 continue;
1449 }
1450 let value = if val.is_empty() {
1454 AttrValue::Bare
1455 } else {
1456 AttrValue::Valued(val)
1457 };
1458 emit_attr(out, tag, key, value, limit)?;
1459 }
1460
1461 if let Some(island) = state.pending_island.take() {
1463 push_checked(out, " data-forma-island=\"", limit)?;
1464 push_u16(out, island.id, limit)?;
1465 push_checked(out, "\" data-forma-component=\"", limit)?;
1466 push_escaped_attr(out, &island.component_name, limit)?;
1467 push_checked(out, "\" data-forma-status=\"pending\"", limit)?;
1468 let trigger_str = match island.trigger {
1469 IslandTrigger::Load => "load",
1470 IslandTrigger::Visible => "visible",
1471 IslandTrigger::Interaction => "interaction",
1472 IslandTrigger::Idle => "idle",
1473 };
1474 push_checked(out, " data-forma-hydrate=\"", limit)?;
1475 push_checked(out, trigger_str, limit)?;
1476 push_checked(out, "\"", limit)?;
1477 if let Some(ref props_json) = island.inline_props {
1478 push_checked(out, " data-forma-props=\"", limit)?;
1479 push_escaped_attr(out, props_json, limit)?;
1480 push_checked(out, "\"", limit)?;
1481 }
1482 }
1483
1484 if let Some(key) = state.pending_list_key.take() {
1486 push_checked(out, " data-forma-key=\"", limit)?;
1487 push_escaped_attr(out, &key, limit)?;
1488 push_checked(out, "\"", limit)?;
1489 }
1490
1491 state.pending_tag_close = true;
1493 state.pending_tag_name.clear();
1494 state.pending_tag_name.push_str(tag);
1495 Ok(())
1496}
1497
1498#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1532enum TextOnlyTag {
1533 Textarea,
1534 Title,
1535 Style,
1536 Script,
1537}
1538
1539impl TextOnlyTag {
1540 fn from_tag(tag: &str) -> Option<Self> {
1541 [Self::Textarea, Self::Title, Self::Style, Self::Script]
1542 .into_iter()
1543 .find(|t| tag.eq_ignore_ascii_case(t.name()))
1544 }
1545
1546 fn name(self) -> &'static str {
1547 match self {
1548 TextOnlyTag::Textarea => "textarea",
1549 TextOnlyTag::Title => "title",
1550 TextOnlyTag::Style => "style",
1551 TextOnlyTag::Script => "script",
1552 }
1553 }
1554}
1555
1556#[derive(Debug)]
1558struct TextOnlyParent {
1559 tag: TextOnlyTag,
1560 started: bool,
1563 value_bound: bool,
1566}
1567
1568fn write_text_only(
1578 out: &mut String,
1579 parent: &mut TextOnlyParent,
1580 text: &str,
1581 limit: usize,
1582) -> Result<(), IrError> {
1583 if text.is_empty() {
1584 return Ok(());
1585 }
1586 if parent.tag == TextOnlyTag::Textarea
1587 && !parent.started
1588 && (text.starts_with('\n') || text.starts_with('\r'))
1589 {
1590 push_checked(out, "\n", limit)?;
1591 }
1592 parent.started = true;
1593 push_escaped_html(out, text, limit)
1594}
1595
1596#[derive(Debug, Default)]
1605enum PendingControl {
1606 #[default]
1607 None,
1608 Textarea { value: Option<String> },
1610 Select {
1612 value: Option<SlotValue>,
1613 multiple: bool,
1614 },
1615 OptionTag { value: Option<String> },
1618}
1619
1620#[derive(Debug)]
1635struct SelectBinding {
1636 wanted: SelectWanted,
1637 matched: bool,
1639}
1640
1641#[derive(Debug)]
1642enum SelectWanted {
1643 One(String),
1644 Any(Vec<String>),
1645}
1646
1647impl SelectBinding {
1648 fn new(value: &SlotValue, multiple: bool) -> Self {
1649 let wanted = match value {
1650 SlotValue::Array(items) if multiple => {
1651 SelectWanted::Any(items.iter().map(js_string).collect())
1652 }
1653 other => SelectWanted::One(other.to_text()),
1655 };
1656 SelectBinding {
1657 wanted,
1658 matched: false,
1659 }
1660 }
1661
1662 fn selects(&mut self, option_value: &str) -> bool {
1664 match &self.wanted {
1665 SelectWanted::Any(values) => values.iter().any(|v| v == option_value),
1666 SelectWanted::One(value) => {
1667 let hit = !self.matched && value == option_value;
1668 self.matched |= hit;
1669 hit
1670 }
1671 }
1672 }
1673}
1674
1675fn js_string(value: &SlotValue) -> String {
1678 match value {
1679 SlotValue::Null => "null".to_string(),
1680 other => other.to_text(),
1681 }
1682}
1683
1684#[derive(Debug)]
1687struct OptionByText {
1688 insert_at: usize,
1691 text: String,
1693}
1694
1695#[derive(Clone, Copy)]
1697enum Prop<'a> {
1698 Static(&'a str),
1699 Slot(&'a SlotValue),
1700}
1701
1702impl Prop<'_> {
1703 fn control_text(self) -> String {
1705 match self {
1706 Prop::Static(s) => s.to_string(),
1707 Prop::Slot(v) => v.to_text(),
1708 }
1709 }
1710
1711 fn is_written(self) -> bool {
1714 !matches!(
1715 self,
1716 Prop::Slot(SlotValue::Null) | Prop::Slot(SlotValue::Bool(false))
1717 )
1718 }
1719
1720 fn written(self) -> Option<String> {
1723 match self {
1724 _ if !self.is_written() => None,
1725 Prop::Slot(SlotValue::Bool(true)) => Some(String::new()),
1726 other => Some(other.control_text()),
1727 }
1728 }
1729
1730 fn to_slot(self) -> SlotValue {
1731 match self {
1732 Prop::Static(s) => SlotValue::Text(s.to_string()),
1733 Prop::Slot(v) => v.clone(),
1734 }
1735 }
1736}
1737
1738fn consume_control_attr(state: &mut WalkState, key: &str, prop: Prop<'_>) -> bool {
1743 match &mut state.pending_control {
1744 PendingControl::None => false,
1745 PendingControl::Textarea { value } => {
1746 if key != "value" {
1747 return false;
1748 }
1749 *value = Some(prop.control_text());
1750 true
1751 }
1752 PendingControl::Select { value, multiple } => match key {
1753 "value" => {
1754 *value = Some(prop.to_slot());
1755 true
1756 }
1757 "multiple" => {
1758 *multiple |= prop.is_written();
1759 false
1760 }
1761 _ => false,
1762 },
1763 PendingControl::OptionTag { value } => match key {
1764 "selected" => true,
1767 "value" => {
1768 if value.is_none() {
1770 *value = prop.written();
1771 }
1772 false
1773 }
1774 _ => false,
1775 },
1776 }
1777}
1778
1779fn enter_element(
1782 out: &mut String,
1783 tag: &str,
1784 state: &mut WalkState,
1785 limit: usize,
1786) -> Result<(), IrError> {
1787 let is = |name: &str| tag.eq_ignore_ascii_case(name);
1788 if is("option") || is("optgroup") {
1790 finish_option_by_text(out, state, limit)?;
1791 }
1792 state.pending_control = if is("textarea") {
1793 PendingControl::Textarea { value: None }
1794 } else if is("select") {
1795 PendingControl::Select {
1796 value: None,
1797 multiple: false,
1798 }
1799 } else if is("option") && state.select.is_some() {
1800 PendingControl::OptionTag { value: None }
1801 } else {
1802 PendingControl::None
1803 };
1804 if state.text_only.is_none() {
1805 state.text_only = TextOnlyTag::from_tag(tag).map(|tag| TextOnlyParent {
1806 tag,
1807 started: false,
1808 value_bound: false,
1809 });
1810 }
1811 Ok(())
1812}
1813
1814fn leave_element(
1816 out: &mut String,
1817 tag: &str,
1818 state: &mut WalkState,
1819 limit: usize,
1820) -> Result<(), IrError> {
1821 let is = |name: &str| tag.eq_ignore_ascii_case(name);
1822 if is("option") || is("optgroup") || is("select") {
1823 finish_option_by_text(out, state, limit)?;
1824 }
1825 if is("select") {
1826 state.select = None;
1827 }
1828 if state
1829 .text_only
1830 .as_ref()
1831 .is_some_and(|parent| is(parent.tag.name()))
1832 {
1833 state.text_only = None;
1834 }
1835 Ok(())
1836}
1837
1838fn close_start_tag(out: &mut String, state: &mut WalkState, limit: usize) -> Result<(), IrError> {
1844 state.pending_tag_close = false;
1845 match std::mem::take(&mut state.pending_control) {
1846 PendingControl::None => {}
1847 PendingControl::Textarea { value } => {
1848 push_checked(out, ">", limit)?;
1849 if let (Some(value), Some(parent)) = (value, state.text_only.as_mut()) {
1850 if parent.tag == TextOnlyTag::Textarea {
1851 parent.value_bound = true;
1852 write_text_only(out, parent, &value, limit)?;
1853 }
1854 }
1855 return Ok(());
1856 }
1857 PendingControl::Select { value, multiple } => {
1858 state.select = value.map(|value| SelectBinding::new(&value, multiple));
1859 }
1860 PendingControl::OptionTag { value: Some(value) } => {
1861 if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
1862 push_checked(out, " selected", limit)?;
1863 }
1864 }
1865 PendingControl::OptionTag { value: None } => {
1866 state.option_by_text = Some(OptionByText {
1867 insert_at: out.len(),
1868 text: String::new(),
1869 });
1870 }
1871 }
1872 push_checked(out, ">", limit)
1873}
1874
1875fn finish_option_by_text(
1880 out: &mut String,
1881 state: &mut WalkState,
1882 limit: usize,
1883) -> Result<(), IrError> {
1884 let Some(option) = state.option_by_text.take() else {
1885 return Ok(());
1886 };
1887 let value = strip_and_collapse_ascii_whitespace(&option.text);
1888 if state.select.as_mut().is_some_and(|s| s.selects(&value)) {
1889 const SELECTED: &str = " selected";
1890 if out.len().saturating_add(SELECTED.len()) > limit {
1891 return Err(IrError::OutputLimitExceeded { limit });
1892 }
1893 out.insert_str(option.insert_at, SELECTED);
1894 }
1895 Ok(())
1896}
1897
1898fn strip_and_collapse_ascii_whitespace(text: &str) -> String {
1901 let mut out = String::with_capacity(text.len());
1902 for word in text.split_ascii_whitespace() {
1903 if !out.is_empty() {
1904 out.push(' ');
1905 }
1906 out.push_str(word);
1907 }
1908 out
1909}
1910
1911pub(crate) fn read_u16(data: &[u8], pos: usize) -> Result<u16, IrError> {
1917 if pos + 2 > data.len() {
1918 return Err(IrError::BufferTooShort {
1919 expected: pos + 2,
1920 actual: data.len(),
1921 });
1922 }
1923 Ok(u16::from_le_bytes(data[pos..pos + 2].try_into().unwrap()))
1924}
1925
1926pub(crate) fn read_u32(data: &[u8], pos: usize) -> Result<u32, IrError> {
1928 if pos + 4 > data.len() {
1929 return Err(IrError::BufferTooShort {
1930 expected: pos + 4,
1931 actual: data.len(),
1932 });
1933 }
1934 Ok(u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()))
1935}
1936
1937pub(crate) fn read_tag_with_attrs<'a>(
1942 ops: &[u8],
1943 pos: usize,
1944 strings: &'a crate::parser::StringTable,
1945) -> Result<(u32, Vec<(&'a str, &'a str)>, usize), IrError> {
1946 let tag_str_idx = read_u32(ops, pos)?;
1947 let attr_count = read_u16(ops, pos + 4)? as usize;
1948 let mut cursor = pos + 6; let mut attrs = Vec::with_capacity(attr_count.min(ops.len().saturating_sub(cursor) / 8));
1954 for _ in 0..attr_count {
1955 let key_idx = read_u32(ops, cursor)?;
1956 let val_idx = read_u32(ops, cursor + 4)?;
1957 let key = strings.get(key_idx)?;
1958 let val = strings.get(val_idx)?;
1959 attrs.push((key, val));
1960 cursor += 8;
1961 }
1962
1963 Ok((tag_str_idx, attrs, cursor))
1964}
1965
1966const MAX_ESCAPE_EXPANSION: usize = 6;
1973
1974fn push_checked(out: &mut String, s: &str, limit: usize) -> Result<(), IrError> {
1981 if out.len().saturating_add(s.len()) > limit {
1982 return Err(IrError::OutputLimitExceeded { limit });
1983 }
1984 out.push_str(s);
1985 Ok(())
1986}
1987
1988fn push_u16(out: &mut String, value: u16, limit: usize) -> Result<(), IrError> {
1994 let mut buf = [0u8; 5];
1996 let mut remaining = value;
1997 let mut start = buf.len();
1998 loop {
1999 start -= 1;
2000 buf[start] = b'0' + (remaining % 10) as u8;
2001 remaining /= 10;
2002 if remaining == 0 {
2003 break;
2004 }
2005 }
2006 let digits = core::str::from_utf8(&buf[start..]).expect("ASCII digits are valid UTF-8");
2007 push_checked(out, digits, limit)
2008}
2009
2010fn push_escaped_html(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
2012 if out
2014 .len()
2015 .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
2016 <= limit
2017 {
2018 for ch in text.chars() {
2019 match ch {
2020 '&' => out.push_str("&"),
2021 '<' => out.push_str("<"),
2022 '>' => out.push_str(">"),
2023 _ => out.push(ch),
2024 }
2025 }
2026 return Ok(());
2027 }
2028 for ch in text.chars() {
2029 match ch {
2030 '&' => push_checked(out, "&", limit)?,
2031 '<' => push_checked(out, "<", limit)?,
2032 '>' => push_checked(out, ">", limit)?,
2033 _ => {
2034 if out.len() >= limit {
2035 return Err(IrError::OutputLimitExceeded { limit });
2036 }
2037 out.push(ch);
2038 }
2039 }
2040 }
2041 Ok(())
2042}
2043
2044fn push_escaped_attr(out: &mut String, text: &str, limit: usize) -> Result<(), IrError> {
2046 if out
2047 .len()
2048 .saturating_add(text.len().saturating_mul(MAX_ESCAPE_EXPANSION))
2049 <= limit
2050 {
2051 for ch in text.chars() {
2052 match ch {
2053 '&' => out.push_str("&"),
2054 '"' => out.push_str("""),
2055 '<' => out.push_str("<"),
2056 '>' => out.push_str(">"),
2057 _ => out.push(ch),
2058 }
2059 }
2060 return Ok(());
2061 }
2062 for ch in text.chars() {
2063 match ch {
2064 '&' => push_checked(out, "&", limit)?,
2065 '"' => push_checked(out, """, limit)?,
2066 '<' => push_checked(out, "<", limit)?,
2067 '>' => push_checked(out, ">", limit)?,
2068 _ => {
2069 if out.len() >= limit {
2070 return Err(IrError::OutputLimitExceeded { limit });
2071 }
2072 out.push(ch);
2073 }
2074 }
2075 }
2076 Ok(())
2077}
2078
2079#[cfg(test)]
2084mod tests {
2085 use super::*;
2086 use crate::parser::test_helpers::{
2087 build_minimal_ir, encode_close_tag, encode_list, encode_open_tag, encode_preload,
2088 encode_show_if, encode_switch, encode_text, encode_try, encode_void_tag,
2089 };
2090 use crate::slot::{SlotData, SlotValue};
2091
2092 fn encode_dyn_text(slot_id: u16, marker_id: u16) -> Vec<u8> {
2096 let mut buf = Vec::new();
2097 buf.push(0x05); buf.extend_from_slice(&slot_id.to_le_bytes());
2099 buf.extend_from_slice(&marker_id.to_le_bytes());
2100 buf
2101 }
2102
2103 fn encode_island_start(island_id: u16) -> Vec<u8> {
2105 let mut buf = vec![0x0B];
2106 buf.extend_from_slice(&island_id.to_le_bytes());
2107 buf
2108 }
2109
2110 fn encode_island_end(island_id: u16) -> Vec<u8> {
2112 let mut buf = vec![0x0C];
2113 buf.extend_from_slice(&island_id.to_le_bytes());
2114 buf
2115 }
2116
2117 fn encode_comment(str_idx: u32) -> Vec<u8> {
2119 let mut buf = vec![0x10];
2120 buf.extend_from_slice(&str_idx.to_le_bytes());
2121 buf
2122 }
2123
2124 fn walk_static(strings: &[&str], opcodes: &[u8]) -> String {
2126 let data = build_minimal_ir(strings, &[], opcodes, &[]);
2127 let module = IrModule::parse(&data).unwrap();
2128 let slots = SlotData::new(0);
2129 walk_to_html(&module, &slots).unwrap()
2130 }
2131
2132 fn walk_with_slots(
2134 strings: &[&str],
2135 slot_decls: &[(u16, u32, u8, u8, &[u8])],
2136 opcodes: &[u8],
2137 slots: &SlotData,
2138 ) -> String {
2139 let data = build_minimal_ir(strings, slot_decls, opcodes, &[]);
2140 let module = IrModule::parse(&data).unwrap();
2141 walk_to_html(&module, slots).unwrap()
2142 }
2143
2144 fn walk_with_islands(
2146 strings: &[&str],
2147 slot_decls: &[(u16, u32, u8, u8, &[u8])],
2148 island_decls: &[(u16, u8, u8, u32, u32, &[u16])],
2149 opcodes: &[u8],
2150 slots: &SlotData,
2151 ) -> String {
2152 let data = build_minimal_ir(strings, slot_decls, opcodes, island_decls);
2153 let module = IrModule::parse(&data).unwrap();
2154 walk_to_html(&module, slots).unwrap()
2155 }
2156
2157 fn encode_list_item_key(key_str_idx: u32) -> Vec<u8> {
2159 let mut buf = vec![0x11]; buf.extend_from_slice(&key_str_idx.to_le_bytes());
2161 buf
2162 }
2163
2164 fn encode_dyn_attr(attr_str_idx: u32, slot_id: u16) -> Vec<u8> {
2166 let mut bytes = vec![0x06]; bytes.extend_from_slice(&attr_str_idx.to_le_bytes());
2168 bytes.extend_from_slice(&slot_id.to_le_bytes());
2169 bytes
2170 }
2171
2172 #[test]
2175 fn walk_static_div() {
2176 let mut opcodes = Vec::new();
2179 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
2180 opcodes.extend_from_slice(&encode_text(3));
2181 opcodes.extend_from_slice(&encode_close_tag(0));
2182
2183 let html = walk_static(&["div", "class", "container", "Hello"], &opcodes);
2184 assert_eq!(html, r#"<div class="container">Hello</div>"#);
2185 }
2186
2187 #[test]
2190 fn walk_void_tag() {
2191 let opcodes = encode_void_tag(0, &[(1, 2)]);
2194
2195 let html = walk_static(&["input", "type", "email"], &opcodes);
2196 assert_eq!(html, r#"<input type="email">"#);
2197 }
2198
2199 #[test]
2202 fn walk_nested_elements() {
2203 let mut opcodes = Vec::new();
2206 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2207 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
2208 opcodes.extend_from_slice(&encode_text(2));
2209 opcodes.extend_from_slice(&encode_close_tag(1));
2210 opcodes.extend_from_slice(&encode_close_tag(0));
2211
2212 let html = walk_static(&["div", "span", "Hi"], &opcodes);
2213 assert_eq!(html, "<div><span>Hi</span></div>");
2214 }
2215
2216 #[test]
2219 fn walk_text_escaping() {
2220 let opcodes = encode_text(0);
2222
2223 let html = walk_static(&["<script>alert('xss')</script>"], &opcodes);
2224 assert_eq!(html, "<script>alert('xss')</script>");
2225 }
2226
2227 #[test]
2230 fn walk_attr_escaping() {
2231 let opcodes = encode_open_tag(0, &[(1, 2)]);
2234
2235 let html = walk_static(&["div", "title", "a\"b"], &opcodes);
2236 assert_eq!(html, r#"<div title="a"b">"#);
2237 }
2238
2239 #[test]
2242 fn walk_empty_element() {
2243 let mut opcodes = Vec::new();
2245 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
2246 opcodes.extend_from_slice(&encode_close_tag(0));
2247
2248 let html = walk_static(&["div"], &opcodes);
2249 assert_eq!(html, "<div></div>");
2250 }
2251
2252 #[test]
2255 fn walk_dyn_text() {
2256 let opcodes = encode_dyn_text(0, 0);
2260
2261 let mut slots = SlotData::new(1);
2262 slots.set(0, SlotValue::Text("World".to_string()));
2263
2264 let html = walk_with_slots(&["greeting"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2265 assert_eq!(html, "<!--f:t0-->World<!--/f:t0-->");
2266 }
2267
2268 #[test]
2269 fn walk_dyn_text_null_emits_zwsp() {
2270 let opcodes = encode_dyn_text(0, 0);
2273 let slots = SlotData::new(1); let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2276 assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
2277 }
2278
2279 #[test]
2280 fn walk_dyn_text_empty_string_emits_zwsp() {
2281 let opcodes = encode_dyn_text(0, 0);
2283 let mut slots = SlotData::new(1);
2284 slots.set(0, SlotValue::Text(String::new()));
2285
2286 let html = walk_with_slots(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2287 assert_eq!(html, "<!--f:t0-->\u{200B}<!--/f:t0-->");
2288 }
2289
2290 #[test]
2293 fn walk_dyn_text_escaping() {
2294 let opcodes = encode_dyn_text(0, 1);
2296
2297 let mut slots = SlotData::new(1);
2298 slots.set(0, SlotValue::Text("<b>bold</b>".to_string()));
2299
2300 let html = walk_with_slots(&["content"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &slots);
2301 assert_eq!(html, "<!--f:t1--><b>bold</b><!--/f:t1-->");
2302 }
2303
2304 #[test]
2307 fn walk_island_markers() {
2308 let mut opcodes = Vec::new();
2312 opcodes.extend_from_slice(&encode_island_start(0));
2313 opcodes.extend_from_slice(&encode_text(0));
2314 opcodes.extend_from_slice(&encode_island_end(0));
2315
2316 let slots = SlotData::new(0);
2317 let html = walk_with_islands(
2318 &["Hello"],
2319 &[],
2320 &[(0, 0x01, 0x01, 0, 0, &[])],
2321 &opcodes,
2322 &slots,
2323 );
2324 assert_eq!(html, "<!--f:i0-->Hello<!--/f:i0-->");
2325 }
2326
2327 #[test]
2330 fn walk_comment() {
2331 let opcodes = encode_comment(0);
2332
2333 let html = walk_static(&["This is a comment"], &opcodes);
2334 assert_eq!(html, "<!--This is a comment-->");
2335 }
2336
2337 #[test]
2340 fn walk_boolean_attr() {
2341 let opcodes = encode_void_tag(0, &[(1, 2)]);
2344
2345 let html = walk_static(&["input", "disabled", ""], &opcodes);
2346 assert_eq!(html, "<input disabled>");
2347 }
2348
2349 fn walk_dyn_attr_with(value: SlotValue) -> String {
2353 let mut opcodes = encode_void_tag(0, &[]);
2354 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
2355 let mut slots = SlotData::new(1);
2356 slots.set(0, value);
2357 walk_with_slots(
2358 &["input", "disabled"],
2359 &[(0, 1, 0x02, 0x00, &[])],
2360 &opcodes,
2361 &slots,
2362 )
2363 }
2364
2365 #[test]
2366 fn dyn_attr_bool_matches_the_client_renderer() {
2367 assert_eq!(
2375 walk_dyn_attr_with(SlotValue::Bool(true)),
2376 "<input disabled>",
2377 "true must render the bare attribute"
2378 );
2379 assert_eq!(
2380 walk_dyn_attr_with(SlotValue::Bool(false)),
2381 "<input>",
2382 "false must omit the attribute, not write disabled=\"false\""
2383 );
2384 assert_eq!(
2385 walk_dyn_attr_with(SlotValue::Null),
2386 "<input>",
2387 "null must omit the attribute"
2388 );
2389 }
2390
2391 #[test]
2392 fn dyn_attr_empty_string_is_a_value_not_an_absence() {
2393 assert_eq!(
2397 walk_dyn_attr_with(SlotValue::Text(String::new())),
2398 "<input disabled=\"\">"
2399 );
2400 assert_eq!(
2401 walk_dyn_attr_with(SlotValue::Text("yes".into())),
2402 "<input disabled=\"yes\">"
2403 );
2404 assert_eq!(
2406 walk_dyn_attr_with(SlotValue::Number(0.0)),
2407 "<input disabled=\"0\">"
2408 );
2409 }
2410
2411 #[test]
2414 fn walk_multiple_attrs() {
2415 let mut opcodes = Vec::new();
2418 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2), (3, 4), (5, 6)]));
2419 opcodes.extend_from_slice(&encode_close_tag(0));
2420
2421 let html = walk_static(
2422 &["div", "id", "main", "class", "container", "data-x", "42"],
2423 &opcodes,
2424 );
2425 assert_eq!(
2426 html,
2427 r#"<div id="main" class="container" data-x="42"></div>"#
2428 );
2429 }
2430
2431 #[test]
2434 fn walk_show_if_true_branch() {
2435 let then_ops = encode_text(1); let else_ops = encode_text(2); let opcodes = encode_show_if(0, &then_ops, &else_ops);
2441
2442 let mut slots = SlotData::new(1);
2443 slots.set(0, SlotValue::Bool(true));
2444
2445 let html = walk_with_slots(
2446 &["visible", "Yes", "No"],
2447 &[(0, 0, 0x02, 0x00, &[])], &opcodes,
2449 &slots,
2450 );
2451 assert!(html.contains("Yes"), "should contain then-branch text");
2452 assert!(!html.contains("No"), "should NOT contain else-branch text");
2453 assert!(html.contains("<!--f:s0-->"), "should have opening marker");
2454 assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
2455 }
2456
2457 #[test]
2460 fn walk_show_if_false_branch() {
2461 let then_ops = encode_text(1);
2464 let else_ops = encode_text(2);
2465 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2466
2467 let mut slots = SlotData::new(1);
2468 slots.set(0, SlotValue::Bool(false));
2469
2470 let html = walk_with_slots(
2471 &["visible", "Yes", "No"],
2472 &[(0, 0, 0x02, 0x00, &[])],
2473 &opcodes,
2474 &slots,
2475 );
2476 assert!(html.contains("No"), "should contain else-branch text");
2477 assert!(!html.contains("Yes"), "should NOT contain then-branch text");
2478 assert!(html.contains("<!--f:s0-->"), "should have opening marker");
2479 assert!(html.contains("<!--/f:s0-->"), "should have closing marker");
2480 }
2481
2482 #[test]
2485 fn walk_show_if_truthy_text() {
2486 let then_ops = encode_text(1);
2488 let else_ops = encode_text(2);
2489 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2490
2491 let mut slots = SlotData::new(1);
2492 slots.set(0, SlotValue::Text("hello".to_string()));
2493
2494 let html = walk_with_slots(
2495 &["greeting", "Shown", "Hidden"],
2496 &[(0, 0, 0x01, 0x00, &[])], &opcodes,
2498 &slots,
2499 );
2500 assert!(
2501 html.contains("Shown"),
2502 "truthy text should render then branch"
2503 );
2504 assert!(
2505 !html.contains("Hidden"),
2506 "truthy text should NOT render else branch"
2507 );
2508 }
2509
2510 #[test]
2513 fn walk_show_if_falsy_null() {
2514 let then_ops = encode_text(1);
2516 let else_ops = encode_text(2);
2517 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2518
2519 let slots = SlotData::new(1); let html = walk_with_slots(
2522 &["maybe", "Present", "Missing"],
2523 &[(0, 0, 0x01, 0x00, &[])],
2524 &opcodes,
2525 &slots,
2526 );
2527 assert!(html.contains("Missing"), "null should render else branch");
2528 assert!(
2529 !html.contains("Present"),
2530 "null should NOT render then branch"
2531 );
2532 }
2533
2534 #[test]
2537 fn walk_show_if_no_else() {
2538 let then_ops = encode_text(1);
2540 let else_ops: Vec<u8> = vec![]; let opcodes = encode_show_if(0, &then_ops, &else_ops);
2542
2543 let slots = SlotData::new(1); let html = walk_with_slots(
2546 &["flag", "Content"],
2547 &[(0, 0, 0x02, 0x00, &[])],
2548 &opcodes,
2549 &slots,
2550 );
2551 assert_eq!(
2552 html, "<!--f:s0--><!--/f:s0-->",
2553 "empty else should produce only markers"
2554 );
2555 }
2556
2557 #[test]
2560 fn walk_show_if_nested() {
2561 let inner_then = encode_text(4);
2565 let inner_else = encode_text(5);
2566 let inner_show_if = encode_show_if(1, &inner_then, &inner_else);
2567
2568 let outer_then = inner_show_if; let outer_else = encode_text(3);
2570 let opcodes = encode_show_if(0, &outer_then, &outer_else);
2571
2572 let mut slots = SlotData::new(2);
2573 slots.set(0, SlotValue::Bool(true)); slots.set(1, SlotValue::Bool(false)); let html = walk_with_slots(
2577 &[
2578 "outer",
2579 "inner",
2580 "outer-yes",
2581 "outer-no",
2582 "inner-yes",
2583 "inner-no",
2584 ],
2585 &[(0, 0, 0x02, 0x00, &[]), (1, 1, 0x02, 0x00, &[])],
2586 &opcodes,
2587 &slots,
2588 );
2589 assert!(
2591 html.contains("inner-no"),
2592 "nested: inner false should render inner else"
2593 );
2594 assert!(
2595 !html.contains("inner-yes"),
2596 "nested: inner true branch should not render"
2597 );
2598 assert!(
2599 !html.contains("outer-no"),
2600 "nested: outer else should not render"
2601 );
2602 assert!(
2604 html.contains("<!--f:s0-->"),
2605 "should have outer opening marker"
2606 );
2607 assert!(
2608 html.contains("<!--/f:s0-->"),
2609 "should have outer closing marker"
2610 );
2611 assert!(
2612 html.contains("<!--f:s1-->"),
2613 "should have inner opening marker"
2614 );
2615 assert!(
2616 html.contains("<!--/f:s1-->"),
2617 "should have inner closing marker"
2618 );
2619 }
2620
2621 #[test]
2624 fn walk_show_if_with_elements() {
2625 let mut then_ops = Vec::new();
2628 then_ops.extend_from_slice(&encode_open_tag(1, &[]));
2629 then_ops.extend_from_slice(&encode_text(2));
2630 then_ops.extend_from_slice(&encode_close_tag(1));
2631
2632 let else_ops = encode_text(3);
2633 let opcodes = encode_show_if(0, &then_ops, &else_ops);
2634
2635 let mut slots = SlotData::new(1);
2636 slots.set(0, SlotValue::Bool(true));
2637
2638 let html = walk_with_slots(
2639 &["flag", "span", "Hello", "Bye"],
2640 &[(0, 0, 0x02, 0x00, &[])],
2641 &opcodes,
2642 &slots,
2643 );
2644 assert_eq!(
2645 html, "<!--f:s0--><span>Hello</span><!--/f:s0-->",
2646 "then branch should produce full HTML element"
2647 );
2648 }
2649
2650 #[test]
2653 fn walk_list_renders_items() {
2654 let body = encode_dyn_text(1, 0);
2658 let opcodes = encode_list(0, 1, &body);
2659
2660 let mut slots = SlotData::new(2);
2661 slots.set(
2662 0,
2663 SlotValue::array(vec![
2664 SlotValue::Text("Alice".to_string()),
2665 SlotValue::Text("Bob".to_string()),
2666 ]),
2667 );
2668
2669 let html = walk_with_slots(
2670 &["items", "item"],
2671 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
2673 &slots,
2674 );
2675 assert!(html.contains("Alice"), "should contain first item");
2676 assert!(html.contains("Bob"), "should contain second item");
2677 assert!(
2678 html.starts_with("<!--f:l0-->"),
2679 "should start with list open marker"
2680 );
2681 assert!(
2682 html.ends_with("<!--/f:l0-->"),
2683 "should end with list close marker"
2684 );
2685 }
2686
2687 #[test]
2690 fn walk_list_empty_array() {
2691 let body = encode_dyn_text(1, 0);
2694 let opcodes = encode_list(0, 1, &body);
2695
2696 let mut slots = SlotData::new(2);
2697 slots.set(0, SlotValue::array(vec![]));
2698
2699 let html = walk_with_slots(
2700 &["items", "item"],
2701 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2702 &opcodes,
2703 &slots,
2704 );
2705 assert_eq!(
2706 html, "<!--f:l0--><!--/f:l0-->",
2707 "empty array should produce only markers"
2708 );
2709 }
2710
2711 #[test]
2714 fn walk_list_with_elements() {
2715 let mut body = Vec::new();
2719 body.extend_from_slice(&encode_open_tag(2, &[]));
2720 body.extend_from_slice(&encode_dyn_text(1, 0));
2721 body.extend_from_slice(&encode_close_tag(2));
2722
2723 let opcodes = encode_list(0, 1, &body);
2724
2725 let mut slots = SlotData::new(2);
2726 slots.set(
2727 0,
2728 SlotValue::array(vec![
2729 SlotValue::Text("A".to_string()),
2730 SlotValue::Text("B".to_string()),
2731 ]),
2732 );
2733
2734 let html = walk_with_slots(
2735 &["items", "item", "li"],
2736 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2737 &opcodes,
2738 &slots,
2739 );
2740 assert!(
2741 html.contains("<li><!--f:t0-->A<!--/f:t0--></li><li><!--f:t0-->B<!--/f:t0--></li>"),
2742 "should render li elements with content, got: {html}"
2743 );
2744 }
2745
2746 #[test]
2749 fn walk_list_null_slot() {
2750 let body = encode_dyn_text(1, 0);
2753 let opcodes = encode_list(0, 1, &body);
2754
2755 let slots = SlotData::new(2); let html = walk_with_slots(
2758 &["items", "item"],
2759 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
2760 &opcodes,
2761 &slots,
2762 );
2763 assert_eq!(
2764 html, "<!--f:l0--><!--/f:l0-->",
2765 "null slot should produce only markers"
2766 );
2767 }
2768
2769 #[test]
2772 fn walk_list_nested() {
2773 let inner_body = encode_dyn_text(2, 0);
2778 let inner_list = encode_list(1, 2, &inner_body);
2779 let opcodes = encode_list(0, 1, &inner_list);
2780
2781 let mut slots = SlotData::new(3);
2782 slots.set(
2783 0,
2784 SlotValue::array(vec![
2785 SlotValue::array(vec![
2786 SlotValue::Text("a".to_string()),
2787 SlotValue::Text("b".to_string()),
2788 ]),
2789 SlotValue::array(vec![SlotValue::Text("c".to_string())]),
2790 ]),
2791 );
2792
2793 let html = walk_with_slots(
2794 &["outer", "inner", "item"],
2795 &[
2796 (0, 0, 0x04, 0x00, &[]),
2797 (1, 1, 0x04, 0x00, &[]),
2798 (2, 2, 0x01, 0x00, &[]),
2799 ],
2800 &opcodes,
2801 &slots,
2802 );
2803 assert!(html.contains("a"), "should contain nested item 'a'");
2805 assert!(html.contains("b"), "should contain nested item 'b'");
2806 assert!(html.contains("c"), "should contain nested item 'c'");
2807 assert!(
2809 html.starts_with("<!--f:l0-->"),
2810 "should have outer list open marker"
2811 );
2812 assert!(
2813 html.ends_with("<!--/f:l0-->"),
2814 "should have outer list close marker"
2815 );
2816 assert!(
2818 html.contains("<!--f:l1-->"),
2819 "should have inner list open marker"
2820 );
2821 assert!(
2822 html.contains("<!--/f:l1-->"),
2823 "should have inner list close marker"
2824 );
2825 }
2826
2827 #[test]
2830 fn walk_list_depth_exceeded() {
2831 let innermost_body = encode_dyn_text(5, 0);
2835 let level5 = encode_list(4, 5, &innermost_body);
2837 let level4 = encode_list(3, 4, &level5);
2838 let level3 = encode_list(2, 3, &level4);
2839 let level2 = encode_list(1, 2, &level3);
2840 let level1 = encode_list(0, 1, &level2);
2841
2842 let mut slots = SlotData::new(6);
2843 slots.set(
2845 0,
2846 SlotValue::array(vec![SlotValue::array(vec![SlotValue::array(vec![
2847 SlotValue::array(vec![SlotValue::array(vec![SlotValue::Text(
2848 "deep".to_string(),
2849 )])]),
2850 ])])]),
2851 );
2852 let data = build_minimal_ir(
2855 &["s0", "s1", "s2", "s3", "s4", "s5"],
2856 &[
2857 (0, 0, 0x04, 0x00, &[]),
2858 (1, 1, 0x04, 0x00, &[]),
2859 (2, 2, 0x04, 0x00, &[]),
2860 (3, 3, 0x04, 0x00, &[]),
2861 (4, 4, 0x04, 0x00, &[]),
2862 (5, 5, 0x01, 0x00, &[]),
2863 ],
2864 &level1,
2865 &[],
2866 );
2867 let module = IrModule::parse(&data).unwrap();
2868 let result = walk_to_html(&module, &slots);
2869 assert!(result.is_err(), "should fail with depth exceeded");
2870 match result.unwrap_err() {
2871 IrError::ListDepthExceeded { max: 4 } => {}
2872 other => panic!("expected ListDepthExceeded {{ max: 4 }}, got {other:?}"),
2873 }
2874 }
2875
2876 #[test]
2879 fn walk_switch_matching_case() {
2880 let case_home_body = encode_text(2); let case_about_body = encode_text(4); let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
2886
2887 let mut slots = SlotData::new(1);
2888 slots.set(0, SlotValue::Text("about".to_string()));
2889
2890 let html = walk_with_slots(
2891 &["page", "home", "Home", "about", "About"],
2892 &[(0, 0, 0x01, 0x00, &[])],
2893 &opcodes,
2894 &slots,
2895 );
2896 assert!(html.contains("About"), "should contain matching case text");
2897 assert!(
2898 !html.contains("Home"),
2899 "should NOT contain non-matching case text"
2900 );
2901 }
2902
2903 #[test]
2906 fn walk_switch_no_match() {
2907 let case_home_body = encode_text(2);
2911 let case_about_body = encode_text(4);
2912 let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
2913
2914 let mut slots = SlotData::new(1);
2915 slots.set(0, SlotValue::Text("missing".to_string()));
2916
2917 let html = walk_with_slots(
2918 &["page", "home", "Home", "about", "About"],
2919 &[(0, 0, 0x01, 0x00, &[])],
2920 &opcodes,
2921 &slots,
2922 );
2923 assert!(
2924 html.is_empty(),
2925 "no match should produce empty output, got: {html}"
2926 );
2927 }
2928
2929 #[test]
2932 fn walk_switch_first_case() {
2933 let case_home_body = encode_text(2);
2936 let case_about_body = encode_text(4);
2937 let opcodes = encode_switch(0, &[(1, &case_home_body), (3, &case_about_body)]);
2938
2939 let mut slots = SlotData::new(1);
2940 slots.set(0, SlotValue::Text("home".to_string()));
2941
2942 let html = walk_with_slots(
2943 &["page", "home", "Home", "about", "About"],
2944 &[(0, 0, 0x01, 0x00, &[])],
2945 &opcodes,
2946 &slots,
2947 );
2948 assert!(html.contains("Home"), "should contain first case text");
2949 assert!(
2950 !html.contains("About"),
2951 "should NOT contain second case text"
2952 );
2953 }
2954
2955 #[test]
2956 fn walk_switch_stops_at_the_first_match() {
2957 let opcodes = encode_switch(0, &[(1, &encode_text(2)), (1, &encode_text(3))]);
2960
2961 let mut slots = SlotData::new(1);
2962 slots.set(0, SlotValue::Text("home".to_string()));
2963
2964 let html = walk_with_slots(
2965 &["page", "home", "FIRST", "SECOND"],
2966 &[(0, 0, 0x01, 0x00, &[])],
2967 &opcodes,
2968 &slots,
2969 );
2970 assert_eq!(html, "FIRST");
2971 }
2972
2973 #[test]
2976 fn walk_switch_with_elements() {
2977 let mut active_body = Vec::new();
2980 active_body.extend_from_slice(&encode_open_tag(2, &[]));
2981 active_body.extend_from_slice(&encode_text(3));
2982 active_body.extend_from_slice(&encode_close_tag(2));
2983
2984 let inactive_body = encode_text(5);
2985 let opcodes = encode_switch(0, &[(1, &active_body), (4, &inactive_body)]);
2986
2987 let mut slots = SlotData::new(1);
2988 slots.set(0, SlotValue::Text("active".to_string()));
2989
2990 let html = walk_with_slots(
2991 &["page", "active", "span", "Active", "inactive", "Inactive"],
2992 &[(0, 0, 0x01, 0x00, &[])],
2993 &opcodes,
2994 &slots,
2995 );
2996 assert_eq!(
2997 html, "<span>Active</span>",
2998 "should produce full HTML element from matching case"
2999 );
3000 }
3001
3002 #[test]
3005 fn walk_try_renders_main() {
3006 let main_ops = encode_text(0);
3009 let fallback_ops = encode_text(1);
3010 let opcodes = encode_try(&main_ops, &fallback_ops);
3011
3012 let html = walk_static(&["Main", "Fallback"], &opcodes);
3013 assert!(html.contains("Main"), "should contain main body text");
3014 assert!(
3015 !html.contains("Fallback"),
3016 "should NOT contain fallback text"
3017 );
3018 }
3019
3020 #[test]
3023 fn walk_preload_font() {
3024 let opcodes = encode_preload(1, 0);
3027
3028 let html = walk_static(&["/_assets/dm-mono.woff2"], &opcodes);
3029 assert!(
3030 html.contains(r#"<link rel="preload" href="/_assets/dm-mono.woff2" as="font" type="font/woff2" crossorigin>"#),
3031 "should produce preload link for font, got: {html}"
3032 );
3033 }
3034
3035 #[test]
3038 fn walk_preload_style() {
3039 let opcodes = encode_preload(2, 0);
3042
3043 let html = walk_static(&["/_assets/styles.css"], &opcodes);
3044 assert!(
3045 html.contains(r#"as="style""#),
3046 "should contain as=\"style\", got: {html}"
3047 );
3048 assert!(
3049 html.contains(r#"href="/_assets/styles.css""#),
3050 "should contain correct href, got: {html}"
3051 );
3052 }
3053
3054 #[test]
3064 fn roundtrip_page_structure() {
3065 let mut opcodes = Vec::new();
3073 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(
3087 &[
3088 "html", "lang", "en", "head", "meta", "charset", "utf-8", "title", "Test", "body",
3089 "div", "id", "app",
3090 ],
3091 &[],
3092 &opcodes,
3093 &[],
3094 );
3095 let module = IrModule::parse(&ir).unwrap();
3096 let slots = SlotData::new(0);
3097 let html = walk_to_html(&module, &slots).unwrap();
3098 assert_eq!(
3099 html,
3100 r#"<html lang="en"><head><meta charset="utf-8"><title>Test</title></head><body><div id="app"></div></body></html>"#
3101 );
3102 }
3103
3104 #[test]
3107 fn roundtrip_dynamic_greeting() {
3108 let mut opcodes = Vec::new();
3114 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(
3119 &["div", "class", "greeting", "content"],
3120 &[(0, 3, 0x01, 0x00, &[])], &opcodes,
3122 &[],
3123 );
3124 let module = IrModule::parse(&ir).unwrap();
3125 let mut slots = SlotData::new(1);
3126 slots.set(0, SlotValue::Text("Hello, World".into()));
3127 let html = walk_to_html(&module, &slots).unwrap();
3128 assert_eq!(
3129 html,
3130 r#"<div class="greeting"><!--f:t0-->Hello, World<!--/f:t0--></div>"#
3131 );
3132 }
3133
3134 #[test]
3137 fn roundtrip_conditional_content() {
3138 let mut then_ops = Vec::new();
3145 then_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
3146 then_ops.extend_from_slice(&encode_text(4));
3147 then_ops.extend_from_slice(&encode_close_tag(1));
3148
3149 let mut else_ops = Vec::new();
3150 else_ops.extend_from_slice(&encode_open_tag(1, &[(2, 3)]));
3151 else_ops.extend_from_slice(&encode_text(5));
3152 else_ops.extend_from_slice(&encode_close_tag(1));
3153
3154 let opcodes = encode_show_if(0, &then_ops, &else_ops);
3155
3156 let ir = build_minimal_ir(
3157 &["auth", "span", "class", "badge", "Logged In", "Guest"],
3158 &[(0, 0, 0x02, 0x00, &[])], &opcodes,
3160 &[],
3161 );
3162
3163 {
3165 let module = IrModule::parse(&ir).unwrap();
3166 let mut slots = SlotData::new(1);
3167 slots.set(0, SlotValue::Bool(true));
3168 let html = walk_to_html(&module, &slots).unwrap();
3169 assert_eq!(
3170 html,
3171 r#"<!--f:s0--><span class="badge">Logged In</span><!--/f:s0-->"#
3172 );
3173 }
3174
3175 {
3177 let module = IrModule::parse(&ir).unwrap();
3178 let mut slots = SlotData::new(1);
3179 slots.set(0, SlotValue::Bool(false));
3180 let html = walk_to_html(&module, &slots).unwrap();
3181 assert_eq!(
3182 html,
3183 r#"<!--f:s0--><span class="badge">Guest</span><!--/f:s0-->"#
3184 );
3185 }
3186 }
3187
3188 #[test]
3191 fn roundtrip_list_rendering() {
3192 let mut body = Vec::new();
3198 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);
3203
3204 let ir = build_minimal_ir(
3205 &["items", "item", "li"],
3206 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])], &opcodes,
3208 &[],
3209 );
3210 let module = IrModule::parse(&ir).unwrap();
3211 let mut slots = SlotData::new(2);
3212 slots.set(
3213 0,
3214 SlotValue::array(vec![
3215 SlotValue::Text("Alice".into()),
3216 SlotValue::Text("Bob".into()),
3217 SlotValue::Text("Charlie".into()),
3218 ]),
3219 );
3220 let html = walk_to_html(&module, &slots).unwrap();
3221 assert_eq!(
3222 html,
3223 "<!--f:l0-->\
3224 <li><!--f:t0-->Alice<!--/f:t0--></li>\
3225 <li><!--f:t0-->Bob<!--/f:t0--></li>\
3226 <li><!--f:t0-->Charlie<!--/f:t0--></li>\
3227 <!--/f:l0-->"
3228 );
3229 }
3230
3231 #[test]
3234 fn roundtrip_switch_view() {
3235 let mut home_body = Vec::new();
3240 home_body.extend_from_slice(&encode_open_tag(2, &[]));
3241 home_body.extend_from_slice(&encode_text(3));
3242 home_body.extend_from_slice(&encode_close_tag(2));
3243
3244 let mut about_body = Vec::new();
3245 about_body.extend_from_slice(&encode_open_tag(2, &[]));
3246 about_body.extend_from_slice(&encode_text(5));
3247 about_body.extend_from_slice(&encode_close_tag(2));
3248
3249 let opcodes = encode_switch(0, &[(1, &home_body), (4, &about_body)]);
3250
3251 let ir = build_minimal_ir(
3252 &["page", "home", "h1", "Home", "about", "About"],
3253 &[(0, 0, 0x01, 0x00, &[])], &opcodes,
3255 &[],
3256 );
3257 let module = IrModule::parse(&ir).unwrap();
3258 let mut slots = SlotData::new(1);
3259 slots.set(0, SlotValue::Text("about".into()));
3260 let html = walk_to_html(&module, &slots).unwrap();
3261 assert_eq!(html, "<h1>About</h1>");
3262 }
3263
3264 #[test]
3267 fn roundtrip_nested_structure() {
3268 let mut then_ops = Vec::new();
3289 then_ops.extend_from_slice(&encode_open_tag(4, &[]));
3290 then_ops.extend_from_slice(&encode_text(5));
3291 then_ops.extend_from_slice(&encode_close_tag(4));
3292
3293 let show_if_ops = encode_show_if(0, &then_ops, &[]);
3294
3295 let mut list_body = Vec::new();
3297 list_body.extend_from_slice(&encode_open_tag(9, &[]));
3298 list_body.extend_from_slice(&encode_dyn_text(2, 0));
3299 list_body.extend_from_slice(&encode_close_tag(9));
3300
3301 let list_ops = encode_list(1, 2, &list_body);
3302
3303 let mut opcodes = Vec::new();
3305 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(
3313 &[
3314 "nav", "class", "main-nav", "auth", "span", "Welcome", "items", "item", "ul", "li",
3315 ],
3316 &[
3317 (0, 3, 0x02, 0x00, &[]), (1, 6, 0x04, 0x00, &[]), (2, 7, 0x01, 0x00, &[]), ],
3321 &opcodes,
3322 &[],
3323 );
3324 let module = IrModule::parse(&ir).unwrap();
3325 let mut slots = SlotData::new(3);
3326 slots.set(0, SlotValue::Bool(true));
3327 slots.set(
3328 1,
3329 SlotValue::array(vec![
3330 SlotValue::Text("Home".into()),
3331 SlotValue::Text("About".into()),
3332 ]),
3333 );
3334
3335 let html = walk_to_html(&module, &slots).unwrap();
3336 assert_eq!(
3337 html,
3338 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>"#
3339 );
3340 }
3341
3342 #[test]
3349 fn walk_island_attrs_on_open_tag() {
3350 let mut opcodes = Vec::new();
3354 opcodes.extend_from_slice(&encode_island_start(0));
3355 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3356 opcodes.extend_from_slice(&encode_text(1));
3357 opcodes.extend_from_slice(&encode_close_tag(0));
3358 opcodes.extend_from_slice(&encode_island_end(0));
3359
3360 let slots = SlotData::new(0);
3361 let html = walk_with_islands(
3362 &["div", "Hello", "AuthForm"],
3363 &[],
3364 &[(0, 0x01, 0x01, 2, 0, &[])],
3366 &opcodes,
3367 &slots,
3368 );
3369 assert_eq!(
3370 html,
3371 r#"<!--f:i0--><div data-forma-island="0" data-forma-component="AuthForm" data-forma-status="pending" data-forma-hydrate="load">Hello</div><!--/f:i0-->"#
3372 );
3373 }
3374
3375 #[test]
3378 fn walk_island_attrs_with_existing_attrs() {
3379 let mut opcodes = Vec::new();
3382 opcodes.extend_from_slice(&encode_island_start(0));
3383 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
3384 opcodes.extend_from_slice(&encode_close_tag(0));
3385 opcodes.extend_from_slice(&encode_island_end(0));
3386
3387 let slots = SlotData::new(0);
3388 let html = walk_with_islands(
3389 &["div", "class", "form", "AuthForm"],
3390 &[],
3391 &[(0, 0x01, 0x01, 3, 0, &[])],
3392 &opcodes,
3393 &slots,
3394 );
3395 assert_eq!(
3396 html,
3397 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-->"#
3398 );
3399 }
3400
3401 #[test]
3404 fn walk_island_attrs_on_void_tag() {
3405 let mut opcodes = Vec::new();
3408 opcodes.extend_from_slice(&encode_island_start(0));
3409 opcodes.extend_from_slice(&encode_void_tag(0, &[]));
3410 opcodes.extend_from_slice(&encode_island_end(0));
3411
3412 let slots = SlotData::new(0);
3413 let html = walk_with_islands(
3414 &["input", "Widget"],
3415 &[],
3416 &[(0, 0x01, 0x01, 1, 0, &[])],
3417 &opcodes,
3418 &slots,
3419 );
3420 assert_eq!(
3421 html,
3422 r#"<!--f:i0--><input data-forma-island="0" data-forma-component="Widget" data-forma-status="pending" data-forma-hydrate="load"><!--/f:i0-->"#
3423 );
3424 }
3425
3426 #[test]
3429 fn walk_inline_props_attribute() {
3430 let mut opcodes = Vec::new();
3435 opcodes.extend_from_slice(&encode_island_start(0));
3436 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3437 opcodes.extend_from_slice(&encode_close_tag(0));
3438 opcodes.extend_from_slice(&encode_island_end(0));
3439
3440 let mut slots = SlotData::new(2);
3441 slots.set(0, SlotValue::Text("Hello".to_string()));
3442 slots.set(1, SlotValue::Number(42.0));
3443
3444 let html = walk_with_islands(
3445 &["div", "MyComponent", "title", "count"],
3446 &[
3447 (0, 2, 0x01, 0x00, &[]), (1, 3, 0x03, 0x00, &[]), ],
3450 &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
3452 &opcodes,
3453 &slots,
3454 );
3455 assert!(
3457 html.contains("data-forma-props="),
3458 "should contain data-forma-props, got: {html}"
3459 );
3460 assert!(
3461 html.contains(r#"data-forma-island="0""#),
3462 "should contain island id, got: {html}"
3463 );
3464 assert!(
3465 html.contains(r#"data-forma-component="MyComponent""#),
3466 "should contain component name, got: {html}"
3467 );
3468 let marker = "data-forma-props=\"";
3474 let props_start = html.find(marker).unwrap() + marker.len();
3475 let props_end = html[props_start..].find('"').unwrap() + props_start;
3476 let raw_attr = &html[props_start..props_end];
3477 let decoded = raw_attr
3478 .replace(""", "\"")
3479 .replace("&", "&")
3480 .replace("<", "<")
3481 .replace(">", ">");
3482 let props_json: serde_json::Value = serde_json::from_str(&decoded).unwrap();
3483 assert_eq!(props_json["title"], "Hello");
3484 assert_eq!(props_json["count"], 42);
3485 }
3486
3487 #[test]
3490 fn walk_list_item_key_on_first_open_tag() {
3491 let mut opcodes = Vec::new();
3494 opcodes.extend_from_slice(&encode_list_item_key(1));
3495 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3496 opcodes.extend_from_slice(&encode_text(2));
3497 opcodes.extend_from_slice(&encode_close_tag(0));
3498
3499 let html = walk_static(&["li", "user-123", "item"], &opcodes);
3500 assert_eq!(html, r#"<li data-forma-key="user-123">item</li>"#);
3501 }
3502
3503 #[test]
3506 fn walk_list_item_key_on_void_tag() {
3507 let mut opcodes = Vec::new();
3510 opcodes.extend_from_slice(&encode_list_item_key(1));
3511 opcodes.extend_from_slice(&encode_void_tag(0, &[]));
3512
3513 let html = walk_static(&["hr", "key-42"], &opcodes);
3514 assert_eq!(html, r#"<hr data-forma-key="key-42">"#);
3515 }
3516
3517 #[test]
3520 fn walk_list_item_key_with_attrs() {
3521 let mut opcodes = Vec::new();
3524 opcodes.extend_from_slice(&encode_list_item_key(3));
3525 opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
3526 opcodes.extend_from_slice(&encode_text(4));
3527 opcodes.extend_from_slice(&encode_close_tag(0));
3528
3529 let html = walk_static(&["li", "class", "item", "k1", "content"], &opcodes);
3530 assert_eq!(html, r#"<li class="item" data-forma-key="k1">content</li>"#);
3531 }
3532
3533 #[test]
3536 fn walk_script_tag_props_mode() {
3537 let mut opcodes = Vec::new();
3541 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
3546 let mut slots = SlotData::new(1);
3547 slots.set(0, SlotValue::Text("Click me".to_string()));
3548
3549 let html = walk_with_islands(
3550 &["div", "Counter", "label"],
3551 &[(0, 2, 0x01, 0x00, &[])], &[(0, 0x01, 0x02, 1, 0, &[0])],
3554 &opcodes,
3555 &slots,
3556 );
3557 assert!(
3559 !html.contains("data-forma-props"),
3560 "ScriptTag mode should not emit inline props, got: {html}"
3561 );
3562 assert!(
3564 html.contains(r#"data-forma-island="0""#),
3565 "should contain island id"
3566 );
3567 assert!(
3569 html.contains(r#"<script id="__forma_islands" type="application/json">"#),
3570 "should contain script tag, got: {html}"
3571 );
3572 assert!(html.contains("</script>"), "should close script tag");
3573 let script_start =
3575 html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
3576 let script_end = html[script_start..].find("</script>").unwrap() + script_start;
3577 let json: serde_json::Value =
3578 serde_json::from_str(&html[script_start..script_end]).unwrap();
3579 assert_eq!(json["0"]["label"], "Click me");
3580 }
3581
3582 #[test]
3585 fn walk_deferred_props_mode() {
3586 let mut opcodes = Vec::new();
3590 opcodes.extend_from_slice(&encode_island_start(0));
3591 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3592 opcodes.extend_from_slice(&encode_close_tag(0));
3593 opcodes.extend_from_slice(&encode_island_end(0));
3594
3595 let mut slots = SlotData::new(1);
3596 slots.set(0, SlotValue::Text("ignored".to_string()));
3597
3598 let html = walk_with_islands(
3599 &["div", "LazyWidget", "data"],
3600 &[(0, 2, 0x01, 0x00, &[])],
3601 &[(0, 0x01, 0x03, 1, 0, &[0])],
3603 &opcodes,
3604 &slots,
3605 );
3606 assert!(
3607 !html.contains("data-forma-props"),
3608 "Deferred mode should not emit inline props, got: {html}"
3609 );
3610 assert!(
3611 !html.contains("__forma_islands"),
3612 "Deferred mode should not emit script tag, got: {html}"
3613 );
3614 assert!(
3616 html.contains(r#"data-forma-island="0""#),
3617 "should contain island id"
3618 );
3619 assert!(
3620 html.contains(r#"data-forma-component="LazyWidget""#),
3621 "should contain component name"
3622 );
3623 }
3624
3625 #[test]
3628 fn walk_island_no_entry_in_table() {
3629 let mut opcodes = Vec::new();
3634 opcodes.extend_from_slice(&encode_island_start(5));
3635 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3636 opcodes.extend_from_slice(&encode_text(1));
3637 opcodes.extend_from_slice(&encode_close_tag(0));
3638 opcodes.extend_from_slice(&encode_island_end(5));
3639
3640 let data = build_minimal_ir(&["div", "Hello"], &[], &opcodes, &[]);
3641 assert_eq!(
3642 IrModule::parse(&data).unwrap_err(),
3643 IrError::UnknownIslandId(5)
3644 );
3645 }
3646
3647 #[test]
3648 fn walk_nested_island_start_before_a_root_element_is_an_error() {
3649 let mut opcodes = Vec::new();
3653 opcodes.extend_from_slice(&encode_island_start(0));
3654 opcodes.extend_from_slice(&encode_island_start(1));
3655 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3656 opcodes.extend_from_slice(&encode_close_tag(0));
3657 opcodes.extend_from_slice(&encode_island_end(1));
3658 opcodes.extend_from_slice(&encode_island_end(0));
3659
3660 let slots = SlotData::new(0);
3661 let data = build_minimal_ir(
3662 &["div", "Outer", "Inner"],
3663 &[],
3664 &opcodes,
3665 &[(0, 0x01, 0x01, 1, 0, &[]), (1, 0x01, 0x01, 2, 3, &[])],
3666 );
3667 let module = IrModule::parse(&data).unwrap();
3668 assert_eq!(
3669 walk_to_html(&module, &slots).unwrap_err(),
3670 IrError::NestedIslandRoot { outer: 0, inner: 1 }
3671 );
3672 }
3673
3674 #[test]
3675 fn walk_island_nested_inside_another_islands_root_is_fine() {
3676 let mut opcodes = Vec::new();
3678 opcodes.extend_from_slice(&encode_island_start(0));
3679 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3680 opcodes.extend_from_slice(&encode_island_start(1));
3681 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3682 opcodes.extend_from_slice(&encode_close_tag(0));
3683 opcodes.extend_from_slice(&encode_island_end(1));
3684 opcodes.extend_from_slice(&encode_close_tag(0));
3685 opcodes.extend_from_slice(&encode_island_end(0));
3686
3687 let slots = SlotData::new(0);
3688 let inner_offset = (encode_island_start(0).len() + encode_open_tag(0, &[]).len()) as u32;
3689 let html = walk_with_islands(
3690 &["div", "Outer", "Inner"],
3691 &[],
3692 &[
3693 (0, 0x01, 0x01, 1, 0, &[]),
3694 (1, 0x01, 0x01, 2, inner_offset, &[]),
3695 ],
3696 &opcodes,
3697 &slots,
3698 );
3699 assert!(html.contains(r#"data-forma-component="Outer""#));
3700 assert!(html.contains(r#"data-forma-component="Inner""#));
3701 }
3702
3703 #[test]
3706 fn walk_inline_props_empty_slots() {
3707 let mut opcodes = Vec::new();
3710 opcodes.extend_from_slice(&encode_island_start(0));
3711 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3712 opcodes.extend_from_slice(&encode_close_tag(0));
3713 opcodes.extend_from_slice(&encode_island_end(0));
3714
3715 let slots = SlotData::new(0);
3716 let html = walk_with_islands(
3717 &["div", "Simple"],
3718 &[],
3719 &[(0, 0x01, 0x01, 1, 0, &[])], &opcodes,
3721 &slots,
3722 );
3723 assert!(
3724 !html.contains("data-forma-props"),
3725 "empty slot_ids should not emit props, got: {html}"
3726 );
3727 assert!(
3728 html.contains(r#"data-forma-island="0""#),
3729 "should contain island id"
3730 );
3731 }
3732
3733 fn walk_island_with_prop_payload(payload: &str) -> String {
3737 let mut opcodes = Vec::new();
3738 opcodes.extend_from_slice(&encode_island_start(0));
3739 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3740 opcodes.extend_from_slice(&encode_close_tag(0));
3741 opcodes.extend_from_slice(&encode_island_end(0));
3742
3743 let mut slots = SlotData::new(1);
3744 slots.set(0, SlotValue::Text(payload.to_string()));
3745 walk_with_islands(
3746 &["div", "Big", "blob"],
3747 &[(0, 2, 0x01, 0x00, &[])],
3748 &[(0, 0x01, 0x01, 1, 0, &[0])],
3749 &opcodes,
3750 &slots,
3751 )
3752 }
3753
3754 #[test]
3755 fn island_props_keep_the_first_slot_for_a_duplicated_name() {
3756 let mut opcodes = Vec::new();
3760 opcodes.extend_from_slice(&encode_island_start(0));
3761 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3762 opcodes.extend_from_slice(&encode_close_tag(0));
3763 opcodes.extend_from_slice(&encode_island_end(0));
3764
3765 let mut slots = SlotData::new(2);
3766 slots.set(0, SlotValue::Text("first".into()));
3767 slots.set(1, SlotValue::Text("second".into()));
3768
3769 let html = walk_with_islands(
3770 &["div", "Comp", "dup"],
3771 &[(0, 2, 0x01, 0x00, &[]), (1, 2, 0x01, 0x00, &[])],
3772 &[(0, 0x01, 0x01, 1, 0, &[0, 1])],
3773 &opcodes,
3774 &slots,
3775 );
3776 assert!(
3777 html.contains(r#"data-forma-props="{"dup":"first"}""#),
3778 "expected the first slot to win, got: {html}"
3779 );
3780 }
3781
3782 #[test]
3783 fn inline_props_under_the_ceiling_stay_inline() {
3784 let html = walk_island_with_prop_payload("small");
3785 assert!(html.contains(r#"data-forma-props="{"blob":"small"}""#));
3786 assert!(!html.contains("__forma_islands"));
3787 }
3788
3789 #[test]
3790 fn inline_props_over_the_ceiling_spill_to_the_shared_block() {
3791 let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
3797 let html = walk_island_with_prop_payload(&payload);
3798 assert!(
3799 !html.contains("data-forma-props"),
3800 "over-size props must not stay in the attribute"
3801 );
3802 assert!(
3803 html.contains(r#"<script id="__forma_islands" type="application/json">"#),
3804 "over-size props must land in the shared block, got: {}",
3805 &html[..html.len().min(200)]
3806 );
3807 assert!(html.contains(&payload), "the payload itself must survive");
3808 }
3809
3810 #[test]
3811 fn island_fragment_keeps_over_size_props_inline() {
3812 let mut opcodes = Vec::new();
3816 opcodes.extend_from_slice(&encode_island_start(0));
3817 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3818 opcodes.extend_from_slice(&encode_close_tag(0));
3819 opcodes.extend_from_slice(&encode_island_end(0));
3820
3821 let payload = "x".repeat(INLINE_PROPS_MAX_BYTES + 1);
3822 let mut slots = SlotData::new(1);
3823 slots.set(0, SlotValue::Text(payload));
3824
3825 let data = build_minimal_ir(
3826 &["div", "Big", "blob"],
3827 &[(0, 2, 0x01, 0x00, &[])],
3828 &opcodes,
3829 &[(0, 0x01, 0x01, 1, 0, &[0])],
3830 );
3831 let module = IrModule::parse(&data).unwrap();
3832 let fragment = walk_island(&module, &slots, 0).unwrap();
3833 assert!(fragment.contains("data-forma-props"));
3834 assert!(!fragment.contains("__forma_islands"));
3835 }
3836
3837 #[test]
3840 fn walk_island_and_list_key_combined() {
3841 let mut opcodes = Vec::new();
3845 opcodes.extend_from_slice(&encode_island_start(0));
3846 opcodes.extend_from_slice(&encode_list_item_key(2));
3847 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3848 opcodes.extend_from_slice(&encode_close_tag(0));
3849 opcodes.extend_from_slice(&encode_island_end(0));
3850
3851 let slots = SlotData::new(0);
3852 let html = walk_with_islands(
3853 &["div", "Card", "item-1"],
3854 &[],
3855 &[(0, 0x01, 0x01, 1, 0, &[])],
3856 &opcodes,
3857 &slots,
3858 );
3859 assert!(
3860 html.contains(r#"data-forma-island="0""#),
3861 "should contain island attrs"
3862 );
3863 assert!(
3864 html.contains(r#"data-forma-key="item-1""#),
3865 "should contain list key attr, got: {html}"
3866 );
3867 }
3868
3869 #[test]
3872 fn walk_multiple_islands_script_tag() {
3873 let mut opcodes = Vec::new();
3877 opcodes.extend_from_slice(&encode_island_start(0));
3879 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3880 opcodes.extend_from_slice(&encode_close_tag(0));
3881 opcodes.extend_from_slice(&encode_island_end(0));
3882 let island1_offset = opcodes.len() as u32;
3884 opcodes.extend_from_slice(&encode_island_start(1));
3885 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3886 opcodes.extend_from_slice(&encode_close_tag(1));
3887 opcodes.extend_from_slice(&encode_island_end(1));
3888
3889 let mut slots = SlotData::new(2);
3890 slots.set(0, SlotValue::Text("hello".to_string()));
3891 slots.set(1, SlotValue::Bool(true));
3892
3893 let html = walk_with_islands(
3894 &["div", "span", "CompA", "CompB", "msg", "flag"],
3895 &[
3896 (0, 4, 0x01, 0x00, &[]), (1, 5, 0x02, 0x00, &[]), ],
3899 &[
3900 (0, 0x01, 0x02, 2, 0, &[0]), (1, 0x01, 0x02, 3, island1_offset, &[1]), ],
3903 &opcodes,
3904 &slots,
3905 );
3906 let script_start =
3908 html.find(r#"type="application/json">"#).unwrap() + r#"type="application/json">"#.len();
3909 let script_end = html[script_start..].find("</script>").unwrap() + script_start;
3910 let json: serde_json::Value =
3911 serde_json::from_str(&html[script_start..script_end]).unwrap();
3912 assert_eq!(json["0"]["msg"], "hello");
3913 assert_eq!(json["1"]["flag"], true);
3914 }
3915
3916 #[test]
3919 fn walk_list_item_key_consumed_once() {
3920 let mut opcodes = Vec::new();
3924 opcodes.extend_from_slice(&encode_list_item_key(2));
3925 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3926 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
3927 opcodes.extend_from_slice(&encode_text(3));
3928 opcodes.extend_from_slice(&encode_close_tag(1));
3929 opcodes.extend_from_slice(&encode_close_tag(0));
3930
3931 let html = walk_static(&["li", "span", "abc", "text"], &opcodes);
3932 assert_eq!(html, r#"<li data-forma-key="abc"><span>text</span></li>"#);
3933 }
3934
3935 #[test]
3938 fn slot_value_to_json_all_types() {
3939 assert_eq!(SlotValue::Null.to_json(), serde_json::Value::Null);
3941 assert_eq!(
3942 SlotValue::Text("hello".to_string()).to_json(),
3943 serde_json::Value::String("hello".to_string())
3944 );
3945 assert_eq!(
3946 SlotValue::Bool(true).to_json(),
3947 serde_json::Value::Bool(true)
3948 );
3949 assert_eq!(
3950 SlotValue::Bool(false).to_json(),
3951 serde_json::Value::Bool(false)
3952 );
3953 assert_eq!(SlotValue::Number(42.0).to_json(), serde_json::json!(42));
3954 assert_eq!(SlotValue::Number(3.15).to_json(), serde_json::json!(3.15));
3955 assert_eq!(
3956 SlotValue::array(vec![
3957 SlotValue::Text("a".to_string()),
3958 SlotValue::Number(1.0)
3959 ])
3960 .to_json(),
3961 serde_json::json!(["a", 1])
3962 );
3963 assert_eq!(
3964 SlotValue::object(vec![(
3965 "key".to_string(),
3966 SlotValue::Text("val".to_string())
3967 ),])
3968 .to_json(),
3969 serde_json::json!({"key": "val"})
3970 );
3971 }
3972
3973 #[test]
3976 fn dyn_attr_emits_attribute_with_value() {
3977 let mut opcodes = Vec::new();
3981 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
3982 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
3983 opcodes.extend_from_slice(&encode_close_tag(0));
3984
3985 let mut slots = SlotData::new(1);
3986 slots.set(0, SlotValue::Text("password".to_string()));
3987
3988 let html = walk_with_slots(
3989 &["input", "type", "input_type"],
3990 &[(0, 2, 0x01, 0x01, b"password")], &opcodes,
3992 &slots,
3993 );
3994 assert_eq!(html, r#"<input type="password"></input>"#);
3995 }
3996
3997 #[test]
4000 fn dyn_attr_skips_when_slot_null() {
4001 let mut opcodes = Vec::new();
4005 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4006 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4007 opcodes.extend_from_slice(&encode_close_tag(0));
4008
4009 let slots = SlotData::new(1); let html = walk_with_slots(
4012 &["div", "class", "cls"],
4013 &[(0, 2, 0x01, 0x00, &[])],
4014 &opcodes,
4015 &slots,
4016 );
4017 assert_eq!(html, "<div></div>");
4018 }
4019
4020 #[test]
4023 fn dyn_attr_multiple_on_same_element() {
4024 let mut opcodes = Vec::new();
4027 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4028 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4029 opcodes.extend_from_slice(&encode_dyn_attr(2, 1));
4030 opcodes.extend_from_slice(&encode_close_tag(0));
4031
4032 let mut slots = SlotData::new(2);
4033 slots.set(0, SlotValue::Text("email".to_string()));
4034 slots.set(1, SlotValue::Text("form-input".to_string()));
4035
4036 let html = walk_with_slots(
4037 &["input", "type", "class", "slot_type", "slot_class"],
4038 &[(0, 3, 0x01, 0x01, b"text"), (1, 4, 0x01, 0x01, b"")],
4039 &opcodes,
4040 &slots,
4041 );
4042 assert_eq!(html, r#"<input type="email" class="form-input"></input>"#);
4043 }
4044
4045 #[test]
4048 fn dyn_attr_on_void_tag() {
4049 let mut opcodes = Vec::new();
4053 opcodes.extend_from_slice(&encode_void_tag(0, &[]));
4054 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
4055
4056 let mut slots = SlotData::new(1);
4057 slots.set(0, SlotValue::Text("checkbox".to_string()));
4058
4059 let html = walk_with_slots(
4060 &["input", "type", "slot_type"],
4061 &[(0, 2, 0x01, 0x01, b"text")],
4062 &opcodes,
4063 &slots,
4064 );
4065 assert_eq!(html, r#"<input type="checkbox">"#);
4066 }
4067
4068 #[test]
4071 fn walk_island_basic() {
4072 let pre_island = encode_open_tag(1, &[]); let island_start = encode_island_start(0);
4087 let open_p = encode_open_tag(2, &[]); let text_hello = encode_text(3);
4089 let close_p = encode_close_tag(2);
4090 let island_end = encode_island_end(0);
4091 let close_div = encode_close_tag(1);
4092
4093 let byte_offset = pre_island.len() as u32; let mut opcodes = Vec::new();
4096 opcodes.extend_from_slice(&pre_island);
4097 opcodes.extend_from_slice(&island_start);
4098 opcodes.extend_from_slice(&open_p);
4099 opcodes.extend_from_slice(&text_hello);
4100 opcodes.extend_from_slice(&close_p);
4101 opcodes.extend_from_slice(&island_end);
4102 opcodes.extend_from_slice(&close_div);
4103
4104 let data = build_minimal_ir(
4105 &["MyIsland", "div", "p", "hello"],
4106 &[],
4107 &opcodes,
4108 &[(0, 0x01, 0x01, 0, byte_offset, &[])], );
4110 let module = IrModule::parse(&data).unwrap();
4111 let slots = SlotData::new(0);
4112
4113 let html = walk_island(&module, &slots, 0).unwrap();
4114 assert_eq!(
4118 html,
4119 "<p data-forma-island=\"0\" data-forma-component=\"MyIsland\" \
4120 data-forma-status=\"pending\" data-forma-hydrate=\"load\">hello</p>"
4121 );
4122 assert!(!html.contains("<!--f:i0-->"));
4125 assert!(!html.contains("<!--/f:i0-->"));
4126 }
4127
4128 #[test]
4129 fn walk_island_attrs_match_walk_to_html() {
4130 let island_start = encode_island_start(0);
4133 let open_div = encode_open_tag(1, &[]);
4134 let dyn_text = encode_dyn_text(0, 0);
4135 let close_div = encode_close_tag(1);
4136 let island_end = encode_island_end(0);
4137
4138 let mut opcodes = Vec::new();
4139 opcodes.extend_from_slice(&island_start);
4140 opcodes.extend_from_slice(&open_div);
4141 opcodes.extend_from_slice(&dyn_text);
4142 opcodes.extend_from_slice(&close_div);
4143 opcodes.extend_from_slice(&island_end);
4144
4145 let data = build_minimal_ir(
4146 &["IslandComp", "div", "greeting"],
4147 &[(0, 2, 0x01, 0x00, &[])],
4148 &opcodes,
4149 &[(0, 0x02, 0x01, 0, 0, &[0])], );
4151 let module = IrModule::parse(&data).unwrap();
4152 let mut slots = SlotData::new(1);
4153 slots.set(0, SlotValue::Text("World".to_string()));
4154
4155 let page = walk_to_html(&module, &slots).unwrap();
4156 let fragment = walk_island(&module, &slots, 0).unwrap();
4157
4158 for attr in [
4159 "data-forma-island=\"0\"",
4160 "data-forma-component=\"IslandComp\"",
4161 "data-forma-status=\"pending\"",
4162 "data-forma-hydrate=\"visible\"",
4163 "data-forma-props=",
4164 ] {
4165 assert!(page.contains(attr), "page missing {attr}: {page}");
4166 assert!(
4167 fragment.contains(attr),
4168 "fragment missing {attr}: {fragment}"
4169 );
4170 }
4171 }
4172
4173 #[test]
4174 fn walk_island_script_tag_mode_inlines_props_in_fragment() {
4175 let mut opcodes = Vec::new();
4181 opcodes.extend_from_slice(&encode_island_start(0));
4182 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4183 opcodes.extend_from_slice(&encode_close_tag(1));
4184 opcodes.extend_from_slice(&encode_island_end(0));
4185
4186 let data = build_minimal_ir(
4187 &["IslandComp", "div", "greeting"],
4188 &[(0, 2, 0x01, 0x00, &[])],
4189 &opcodes,
4190 &[(0, 0x01, 0x02, 0, 0, &[0])], );
4192 let module = IrModule::parse(&data).unwrap();
4193 let mut slots = SlotData::new(1);
4194 slots.set(0, SlotValue::Text("World".to_string()));
4195
4196 let fragment = walk_island(&module, &slots, 0).unwrap();
4197 assert!(
4198 fragment.contains("data-forma-props="),
4199 "ScriptTag island fragment must inline its props: {fragment}"
4200 );
4201 assert!(
4202 !fragment.contains("__forma_islands"),
4203 "fragment must not emit a second islands block: {fragment}"
4204 );
4205 }
4206
4207 #[test]
4208 fn walk_island_nested_script_tag_island_keeps_its_props() {
4209 let mut opcodes = encode_island_start(0);
4213 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4214 let inner_offset = opcodes.len() as u32;
4215 opcodes.extend_from_slice(&encode_island_start(1));
4216 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4217 opcodes.extend_from_slice(&encode_close_tag(1));
4218 opcodes.extend_from_slice(&encode_island_end(1));
4219 opcodes.extend_from_slice(&encode_close_tag(1));
4220 opcodes.extend_from_slice(&encode_island_end(0));
4221
4222 let data = build_minimal_ir(
4223 &["Outer", "div", "greeting", "Inner"],
4224 &[(0, 2, 0x01, 0x00, &[])],
4225 &opcodes,
4226 &[
4227 (0, 0x01, 0x01, 0, 0, &[]),
4228 (1, 0x01, 0x02, 3, inner_offset, &[0]), ],
4230 );
4231 let module = IrModule::parse(&data).unwrap();
4232 let mut slots = SlotData::new(1);
4233 slots.set(0, SlotValue::Text("World".into()));
4234
4235 let fragment = walk_island(&module, &slots, 0).unwrap();
4236 assert!(
4237 fragment.contains("data-forma-component=\"Inner\""),
4238 "nested island must render: {fragment}"
4239 );
4240 assert!(
4241 fragment.contains("data-forma-props="),
4242 "nested ScriptTag island must keep its props inline: {fragment}"
4243 );
4244 assert!(!fragment.contains("__forma_islands"), "{fragment}");
4245 }
4246
4247 #[test]
4248 fn walk_island_not_found() {
4249 let opcodes = encode_text(0);
4251 let data = build_minimal_ir(&["Hello"], &[], &opcodes, &[]);
4252 let module = IrModule::parse(&data).unwrap();
4253 let slots = SlotData::new(0);
4254
4255 let result = walk_island(&module, &slots, 5);
4256 assert!(result.is_err());
4257 match result.unwrap_err() {
4258 IrError::IslandNotFound(5) => {} other => panic!("expected IslandNotFound(5), got {other:?}"),
4260 }
4261 }
4262
4263 #[test]
4264 fn walk_island_with_dyn_text() {
4265 let island_start = encode_island_start(0);
4270 let open_div = encode_open_tag(1, &[]); let dyn_text = encode_dyn_text(0, 0); let close_div = encode_close_tag(1);
4273 let island_end = encode_island_end(0);
4274
4275 let byte_offset = 0u32; let mut opcodes = Vec::new();
4278 opcodes.extend_from_slice(&island_start);
4279 opcodes.extend_from_slice(&open_div);
4280 opcodes.extend_from_slice(&dyn_text);
4281 opcodes.extend_from_slice(&close_div);
4282 opcodes.extend_from_slice(&island_end);
4283
4284 let data = build_minimal_ir(
4285 &["IslandComp", "div", "greeting"],
4286 &[(0, 2, 0x01, 0x00, &[])], &opcodes,
4288 &[(0, 0x01, 0x01, 0, byte_offset, &[0])], );
4290 let module = IrModule::parse(&data).unwrap();
4291
4292 let mut slots = SlotData::new(1);
4293 slots.set(0, SlotValue::Text("World".to_string()));
4294
4295 let html = walk_island(&module, &slots, 0).unwrap();
4296 assert_eq!(
4298 html,
4299 "<div data-forma-island=\"0\" data-forma-component=\"IslandComp\" \
4300 data-forma-status=\"pending\" data-forma-hydrate=\"load\" \
4301 data-forma-props=\"{"greeting":"World"}\">\
4302 <!--f:t0-->World<!--/f:t0--></div>"
4303 );
4304 }
4305
4306 #[test]
4307 fn walk_island_byte_offset_out_of_bounds() {
4308 let opcodes = encode_text(0); let data = build_minimal_ir(
4316 &["BadIsland", "hello"],
4317 &[],
4318 &opcodes,
4319 &[(0, 0x01, 0x01, 0, 9999, &[])], );
4321 let err = IrModule::parse(&data).unwrap_err();
4322 assert_eq!(
4323 err,
4324 IrError::IslandOffsetMismatch {
4325 island_id: 0,
4326 byte_offset: 9999
4327 }
4328 );
4329 }
4330
4331 fn encode_prop(src_slot_id: u16, prop_str_idx: u32, target_slot_id: u16) -> Vec<u8> {
4335 let mut buf = vec![0x12]; buf.extend_from_slice(&src_slot_id.to_le_bytes());
4337 buf.extend_from_slice(&prop_str_idx.to_le_bytes());
4338 buf.extend_from_slice(&target_slot_id.to_le_bytes());
4339 buf
4340 }
4341
4342 #[test]
4345 fn walk_prop_in_list_body() {
4346 let mut body = Vec::new();
4353 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);
4356
4357 let mut slots = SlotData::new(3);
4358 slots.set(
4359 0,
4360 SlotValue::array(vec![
4361 SlotValue::object(vec![(
4362 "name".to_string(),
4363 SlotValue::Text("Alice".to_string()),
4364 )]),
4365 SlotValue::object(vec![(
4366 "name".to_string(),
4367 SlotValue::Text("Bob".to_string()),
4368 )]),
4369 ]),
4370 );
4371
4372 let html = walk_with_slots(
4373 &["items", "item", "name"],
4374 &[
4375 (0, 0, 0x04, 0x00, &[]), (1, 1, 0x05, 0x00, &[]), (2, 2, 0x01, 0x00, &[]), ],
4379 &opcodes,
4380 &slots,
4381 );
4382 assert!(html.contains("Alice"), "should contain Alice, got: {html}");
4383 assert!(html.contains("Bob"), "should contain Bob, got: {html}");
4384 }
4385
4386 #[test]
4389 fn walk_prop_multiple_properties() {
4390 let mut body = Vec::new();
4397 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);
4408
4409 let mut slots = SlotData::new(4);
4410 slots.set(
4411 0,
4412 SlotValue::array(vec![
4413 SlotValue::object(vec![
4414 ("name".to_string(), SlotValue::Text("Alice".to_string())),
4415 (
4416 "email".to_string(),
4417 SlotValue::Text("alice@test.com".to_string()),
4418 ),
4419 ]),
4420 SlotValue::object(vec![
4421 ("name".to_string(), SlotValue::Text("Bob".to_string())),
4422 (
4423 "email".to_string(),
4424 SlotValue::Text("bob@test.com".to_string()),
4425 ),
4426 ]),
4427 ]),
4428 );
4429
4430 let html = walk_with_slots(
4431 &["rows", "row", "name", "email", "tr", "td"],
4432 &[
4433 (0, 0, 0x04, 0x00, &[]),
4434 (1, 1, 0x05, 0x00, &[]),
4435 (2, 2, 0x01, 0x00, &[]),
4436 (3, 3, 0x01, 0x00, &[]),
4437 ],
4438 &opcodes,
4439 &slots,
4440 );
4441 assert!(
4442 html.contains("<td><!--f:t0-->Alice<!--/f:t0--></td>"),
4443 "should contain Alice td, got: {html}"
4444 );
4445 assert!(
4446 html.contains("<td><!--f:t1-->alice@test.com<!--/f:t1--></td>"),
4447 "should contain alice email, got: {html}"
4448 );
4449 assert!(
4450 html.contains("<td><!--f:t0-->Bob<!--/f:t0--></td>"),
4451 "should contain Bob td, got: {html}"
4452 );
4453 assert!(
4454 html.contains("<td><!--f:t1-->bob@test.com<!--/f:t1--></td>"),
4455 "should contain bob email, got: {html}"
4456 );
4457 }
4458
4459 #[test]
4462 fn walk_prop_on_non_object() {
4463 let mut opcodes = Vec::new();
4466 opcodes.extend_from_slice(&encode_prop(0, 1, 1));
4467 opcodes.extend_from_slice(&encode_dyn_text(1, 0));
4468
4469 let mut slots = SlotData::new(2);
4470 slots.set(0, SlotValue::Text("hello".to_string()));
4471
4472 let html = walk_with_slots(
4473 &["src", "name"],
4474 &[(0, 0, 0x01, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4475 &opcodes,
4476 &slots,
4477 );
4478 assert!(
4480 html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4481 "non-object PROP should produce empty text, got: {html}"
4482 );
4483 }
4484
4485 #[test]
4488 fn walk_prop_missing_property() {
4489 let mut opcodes = Vec::new();
4492 opcodes.extend_from_slice(&encode_prop(0, 2, 1));
4493 opcodes.extend_from_slice(&encode_dyn_text(1, 0));
4494
4495 let mut slots = SlotData::new(2);
4496 slots.set(
4497 0,
4498 SlotValue::object(vec![(
4499 "name".to_string(),
4500 SlotValue::Text("Alice".to_string()),
4501 )]),
4502 );
4503
4504 let html = walk_with_slots(
4505 &["src", "target", "missing"],
4506 &[(0, 0, 0x05, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4507 &opcodes,
4508 &slots,
4509 );
4510 assert!(
4511 html.contains("<!--f:t0-->\u{200B}<!--/f:t0-->"),
4512 "missing property should produce empty text, got: {html}"
4513 );
4514 }
4515
4516 #[test]
4519 fn walk_nested_show_if_within_limit() {
4520 let inner = encode_text(5);
4524 let level4 = encode_show_if(4, &inner, &[]);
4525 let level3 = encode_show_if(3, &level4, &[]);
4526 let level2 = encode_show_if(2, &level3, &[]);
4527 let level1 = encode_show_if(1, &level2, &[]);
4528 let level0 = encode_show_if(0, &level1, &[]);
4529
4530 let mut slots = SlotData::new(5);
4531 for i in 0..5 {
4532 slots.set(i, SlotValue::Bool(true));
4533 }
4534
4535 let html = walk_with_slots(
4536 &["s0", "s1", "s2", "s3", "s4", "deep"],
4537 &[
4538 (0, 0, 0x02, 0x00, &[]),
4539 (1, 1, 0x02, 0x00, &[]),
4540 (2, 2, 0x02, 0x00, &[]),
4541 (3, 3, 0x02, 0x00, &[]),
4542 (4, 4, 0x02, 0x00, &[]),
4543 ],
4544 &level0,
4545 &slots,
4546 );
4547 assert!(
4548 html.contains("deep"),
4549 "5 levels of nesting should succeed, got: {html}"
4550 );
4551 }
4552
4553 fn script_tag_props_html(payload: &str) -> (String, String) {
4556 let mut opcodes = Vec::new();
4559 opcodes.extend_from_slice(&encode_island_start(0));
4560 opcodes.extend_from_slice(&encode_open_tag(0, &[]));
4561 opcodes.extend_from_slice(&encode_close_tag(0));
4562 opcodes.extend_from_slice(&encode_island_end(0));
4563
4564 let mut slots = SlotData::new(1);
4565 slots.set(0, SlotValue::Text(payload.to_string()));
4566
4567 let html = walk_with_islands(
4568 &["div", "Comp", "payload"],
4569 &[(0, 2, 0x01, 0x00, &[])],
4570 &[(0, 0x01, 0x02, 1, 0, &[0])],
4571 &opcodes,
4572 &slots,
4573 );
4574
4575 const OPEN: &str = "type=\"application/json\">";
4576 let start = html.find(OPEN).expect("islands script block") + OPEN.len();
4577 let end = html[start..].find("</script>").expect("script close") + start;
4578 let body = html[start..end].to_string();
4579 (html, body)
4580 }
4581
4582 #[test]
4583 fn walk_script_tag_props_body_has_no_raw_angle_brackets() {
4584 let (_html, body) = script_tag_props_html("</script>alert(1)");
4589 assert!(
4590 !body.contains('<') && !body.contains('>'),
4591 "islands block must contain no raw angle brackets, got: {body}"
4592 );
4593 assert!(body.contains("\\u003c/script\\u003e"), "got: {body}");
4594
4595 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4599 assert_eq!(parsed["0"]["payload"], "</script>alert(1)");
4600 }
4601
4602 #[test]
4603 fn walk_script_tag_props_escapes_comment_script_breakout() {
4604 let payload = "<!--<script>PAYLOAD";
4610 let (html, body) = script_tag_props_html(payload);
4611
4612 assert!(
4613 !html.contains("<!--<script"),
4614 "must not emit the script-data-double-escape trigger, got: {html}"
4615 );
4616 assert!(!body.contains('<') && !body.contains('>'), "got: {body}");
4617
4618 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
4619 assert_eq!(parsed["0"]["payload"], payload);
4620 }
4621
4622 #[test]
4623 #[ignore = "requires admin/dist IR files — run in monorepo or after `npm run build`"]
4624 fn walk_real_benchmark_ir() {
4625 let dist_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../admin/dist");
4627 let ir_path = std::fs::read_dir(&dist_dir)
4628 .expect("admin/dist must exist")
4629 .filter_map(|e| e.ok())
4630 .find(|e| {
4631 let name = e.file_name();
4632 let s = name.to_string_lossy();
4633 s.starts_with("platform-benchmark.") && s.ends_with(".ir")
4634 })
4635 .map(|e| e.path())
4636 .expect("platform-benchmark.*.ir must exist in admin/dist");
4637 let data = std::fs::read(&ir_path).expect("benchmark IR file must exist");
4638 let module = IrModule::parse(&data).expect("IR must parse");
4639
4640 let slots = SlotData::new_from_defaults(&module.slots);
4642 let html = walk_to_html(&module, &slots).expect("walk must succeed");
4643
4644 assert!(html.contains("<!--f:i0-->"), "missing island 0 start");
4646 assert!(html.contains("<!--/f:i0-->"), "missing island 0 end");
4647 assert!(
4648 html.contains("data-forma-island=\"0\""),
4649 "missing island 0 attr"
4650 );
4651
4652 assert!(
4655 html.contains("Search"),
4656 "FilterBar must contain 'Search' label text in SSR"
4657 );
4658 assert!(
4659 html.contains("filter-group"),
4660 "FilterBar must contain filter-group divs in SSR"
4661 );
4662 assert!(
4663 html.contains("Sort by"),
4664 "FilterBar must contain 'Sort by' label text in SSR"
4665 );
4666
4667 assert!(
4669 html.contains("Performance"),
4670 "PerfPanel must contain 'Performance' heading in SSR"
4671 );
4672
4673 assert!(
4676 html.contains("benchmark-data-table"),
4677 "BenchmarkDataTable root class must exist"
4678 );
4679
4680 let start = html
4684 .find("<!--f:i0-->")
4685 .expect("FilterBar island start marker");
4686 let end = html
4687 .find("<!--/f:i0-->")
4688 .expect("FilterBar island end marker");
4689 assert!(end > start, "island end marker must follow its start");
4690 }
4691
4692 #[test]
4695 fn walk_output_limit_trips_inside_one_opcode() {
4696 let big = "&".repeat(4096); let attrs: Vec<(u32, u32)> = (0..200).map(|_| (1u32, 2u32)).collect();
4702 let opcodes = encode_open_tag(0, &attrs);
4703
4704 let data = build_minimal_ir(&["div", "data-x", &big], &[], &opcodes, &[]);
4705 let module = IrModule::parse(&data).unwrap();
4706 let slots = SlotData::new(0);
4707
4708 let limits = WalkLimits {
4709 max_output_bytes: 64 * 1024,
4710 ..WalkLimits::default()
4711 };
4712 let err = walk_to_html_with_limits(&module, &slots, limits).unwrap_err();
4713 assert_eq!(err, IrError::OutputLimitExceeded { limit: 64 * 1024 });
4714
4715 let ok = WalkLimits {
4717 max_output_bytes: 64 * 1024 * 1024,
4718 ..WalkLimits::default()
4719 };
4720 assert!(walk_to_html_with_limits(&module, &slots, ok).is_ok());
4721 }
4722
4723 #[test]
4724 fn walk_output_limit_trips_on_a_single_dyn_text_value() {
4725 let opcodes = encode_dyn_text(0, 0);
4727 let data = build_minimal_ir(&["msg"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &[]);
4728 let module = IrModule::parse(&data).unwrap();
4729
4730 let mut slots = SlotData::new(1);
4731 slots.set(0, SlotValue::Text("x".repeat(100_000)));
4732
4733 let limits = WalkLimits {
4734 max_output_bytes: 4096,
4735 ..WalkLimits::default()
4736 };
4737 assert!(matches!(
4738 walk_to_html_with_limits(&module, &slots, limits),
4739 Err(IrError::OutputLimitExceeded { .. })
4740 ));
4741 }
4742
4743 #[test]
4746 fn walk_list_budget_exceeded() {
4747 let body = encode_text(2);
4750 let inner = encode_list(0, 1, &body);
4751 let opcodes = encode_list(0, 1, &inner);
4752
4753 let data = build_minimal_ir(
4754 &["items", "item", "x"],
4755 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4756 &opcodes,
4757 &[],
4758 );
4759 let module = IrModule::parse(&data).unwrap();
4760
4761 let mut slots = SlotData::new(2);
4762 slots.set(
4763 0,
4764 SlotValue::array((0..300).map(|_| SlotValue::Text("a".into())).collect()),
4765 );
4766
4767 let err = walk_to_html(&module, &slots).unwrap_err();
4768 assert_eq!(
4769 err,
4770 IrError::ListBudgetExceeded {
4771 max: DEFAULT_MAX_LIST_ITERATIONS
4772 }
4773 );
4774 assert_ne!(
4778 err,
4779 IrError::ListDepthExceeded {
4780 max: MAX_LIST_DEPTH
4781 }
4782 );
4783 }
4784
4785 fn patch_u32(buf: &mut [u8], at: usize, value: u32) {
4789 buf[at..at + 4].copy_from_slice(&value.to_le_bytes());
4790 }
4791
4792 #[test]
4793 fn parse_rejects_show_if_then_len_past_the_stream() {
4794 let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
4796 patch_u32(&mut opcodes, 3, 0xFFFF_FF00); let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
4798 assert!(matches!(
4799 IrModule::parse(&data),
4800 Err(IrError::MalformedControlFlow { .. })
4801 ));
4802 }
4803
4804 #[test]
4805 fn parse_rejects_show_if_marker_byte_that_is_not_show_else() {
4806 let mut opcodes = encode_show_if(0, &encode_text(1), &encode_text(1));
4809 patch_u32(&mut opcodes, 3, 4); let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
4811 assert!(matches!(
4812 IrModule::parse(&data),
4813 Err(IrError::MalformedControlFlow { .. })
4814 ));
4815 }
4816
4817 #[test]
4818 fn parse_rejects_list_body_len_past_the_stream() {
4819 let mut opcodes = encode_list(0, 1, &encode_text(2));
4821 patch_u32(&mut opcodes, 5, 0xFFFF_FF00);
4822 let data = build_minimal_ir(
4823 &["items", "item", "x"],
4824 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4825 &opcodes,
4826 &[],
4827 );
4828 assert!(matches!(
4829 IrModule::parse(&data),
4830 Err(IrError::MalformedControlFlow { .. })
4831 ));
4832 }
4833
4834 #[test]
4835 fn parse_rejects_switch_case_body_len_past_the_stream() {
4836 let mut opcodes = encode_switch(0, &[(1, &encode_text(2)), (3, &encode_text(2))]);
4839 patch_u32(&mut opcodes, 9, 0xFFFF_FF00);
4841 let data = build_minimal_ir(
4842 &["mode", "a", "x", "b"],
4843 &[(0, 0, 0x01, 0x00, &[])],
4844 &opcodes,
4845 &[],
4846 );
4847 assert!(matches!(
4848 IrModule::parse(&data),
4849 Err(IrError::MalformedControlFlow { .. })
4850 ));
4851 }
4852
4853 #[test]
4854 fn parse_rejects_fallback_skip_past_the_stream() {
4855 let mut opcodes = encode_try(&encode_text(0), &encode_text(0));
4857 patch_u32(&mut opcodes, 1, 0xFFFF_FF00);
4858 let data = build_minimal_ir(&["hi"], &[], &opcodes, &[]);
4859 assert!(matches!(
4860 IrModule::parse(&data),
4861 Err(IrError::MalformedControlFlow { .. })
4862 ));
4863 }
4864
4865 #[test]
4866 fn parse_accepts_zero_length_else_branch_known_residual() {
4867 let opcodes = encode_show_if(0, &encode_text(1), &[]);
4873 let data = build_minimal_ir(&["flag", "hi"], &[(0, 0, 0x02, 0x00, &[])], &opcodes, &[]);
4874 assert!(IrModule::parse(&data).is_ok());
4875 }
4876
4877 #[test]
4880 fn walk_stray_fallback_in_a_nested_range_is_rejected_at_load() {
4881 let then_ops = vec![0x0E]; let mut main_ops = encode_show_if(0, &then_ops, &[]);
4890 main_ops.extend_from_slice(&encode_text(1));
4891 let opcodes = encode_try(&main_ops, &encode_text(2));
4892
4893 let data = build_minimal_ir(
4894 &["flag", "main", "fb"],
4895 &[(0, 0, 0x02, 0x00, &[])],
4896 &opcodes,
4897 &[],
4898 );
4899 assert!(matches!(
4900 IrModule::parse(&data).unwrap_err(),
4901 IrError::MalformedControlFlow { .. }
4902 ));
4903 }
4904
4905 #[test]
4906 fn walk_try_without_a_fallback_is_rejected_at_load() {
4907 let mut opcodes = vec![0x0D]; opcodes.extend_from_slice(&4u32.to_le_bytes()); opcodes.extend_from_slice(&encode_text(0));
4912
4913 let data = build_minimal_ir(&["main"], &[], &opcodes, &[]);
4914 assert!(matches!(
4915 IrModule::parse(&data).unwrap_err(),
4916 IrError::MalformedControlFlow { .. }
4917 ));
4918 }
4919
4920 #[test]
4923 fn walk_island_attrs_survive_an_empty_show_if_before_the_root() {
4924 let mut opcodes = encode_island_start(0);
4929 opcodes.extend_from_slice(&encode_show_if(0, &[], &[]));
4930 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4931 opcodes.extend_from_slice(&encode_close_tag(1));
4932 opcodes.extend_from_slice(&encode_island_end(0));
4933
4934 let slots = SlotData::new(1);
4935 let html = walk_with_islands(
4936 &["Comp", "div", "flag"],
4937 &[(0, 2, 0x02, 0x00, &[])],
4938 &[(0, 0x01, 0x01, 0, 0, &[])],
4939 &opcodes,
4940 &slots,
4941 );
4942 assert!(
4943 html.contains("<div data-forma-island=\"0\""),
4944 "island root must keep its attributes, got: {html}"
4945 );
4946 }
4947
4948 #[test]
4949 fn walk_list_key_survives_an_empty_show_if_before_the_item_element() {
4950 let mut body = encode_list_item_key(3);
4951 body.extend_from_slice(&encode_show_if(1, &[], &[]));
4952 body.extend_from_slice(&encode_open_tag(2, &[]));
4953 body.extend_from_slice(&encode_close_tag(2));
4954 let opcodes = encode_list(0, 1, &body);
4955
4956 let mut slots = SlotData::new(2);
4957 slots.set(
4958 0,
4959 SlotValue::array(vec![
4960 SlotValue::Text("a".into()),
4961 SlotValue::Text("b".into()),
4962 ]),
4963 );
4964
4965 let html = walk_with_slots(
4966 &["items", "item", "li", "k"],
4967 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
4968 &opcodes,
4969 &slots,
4970 );
4971 assert_eq!(
4972 html.matches("data-forma-key=\"k\"").count(),
4973 2,
4974 "every item element must keep its key, got: {html}"
4975 );
4976 }
4977
4978 #[test]
4979 fn walk_island_attrs_do_not_leak_past_the_island_end() {
4980 let mut opcodes = encode_island_start(0);
4984 opcodes.extend_from_slice(&encode_island_end(0));
4985 opcodes.extend_from_slice(&encode_open_tag(1, &[]));
4986 opcodes.extend_from_slice(&encode_close_tag(1));
4987
4988 let slots = SlotData::new(0);
4989 let html = walk_with_islands(
4990 &["Comp", "div"],
4991 &[],
4992 &[(0, 0x01, 0x01, 0, 0, &[])],
4993 &opcodes,
4994 &slots,
4995 );
4996 assert!(
4997 !html.contains("data-forma-island"),
4998 "island attributes must not leak onto an element outside the island, got: {html}"
4999 );
5000 }
5001
5002 #[test]
5003 fn walk_list_key_does_not_leak_past_the_list() {
5004 let body = encode_list_item_key(2); let mut opcodes = encode_list(0, 1, &body);
5006 opcodes.extend_from_slice(&encode_open_tag(3, &[]));
5007 opcodes.extend_from_slice(&encode_close_tag(3));
5008
5009 let mut slots = SlotData::new(2);
5010 slots.set(0, SlotValue::array(vec![SlotValue::Text("a".into())]));
5011
5012 let html = walk_with_slots(
5013 &["items", "item", "k", "div"],
5014 &[(0, 0, 0x04, 0x00, &[]), (1, 1, 0x01, 0x00, &[])],
5015 &opcodes,
5016 &slots,
5017 );
5018 assert!(
5019 !html.contains("data-forma-key"),
5020 "list key must not leak onto an element after the list, got: {html}"
5021 );
5022 }
5023
5024 #[test]
5027 fn walk_rejects_hostile_tag_name() {
5028 let opcodes = encode_open_tag(0, &[]);
5031 let data = build_minimal_ir(&["div onload=\"alert(1)\""], &[], &opcodes, &[]);
5032 let module = IrModule::parse(&data).unwrap();
5033 let slots = SlotData::new(0);
5034 assert!(matches!(
5035 walk_to_html(&module, &slots),
5036 Err(IrError::InvalidName(_))
5037 ));
5038 }
5039
5040 #[test]
5041 fn walk_rejects_hostile_close_tag_name() {
5042 let mut opcodes = encode_open_tag(0, &[]);
5043 opcodes.extend_from_slice(&encode_close_tag(1));
5044 let data = build_minimal_ir(&["div", "div><script"], &[], &opcodes, &[]);
5045 let module = IrModule::parse(&data).unwrap();
5046 let slots = SlotData::new(0);
5047 assert!(matches!(
5048 walk_to_html(&module, &slots),
5049 Err(IrError::InvalidName(_))
5050 ));
5051 }
5052
5053 #[test]
5054 fn walk_drops_hostile_attribute_names() {
5055 let opcodes = encode_open_tag(0, &[(1, 2), (3, 2)]);
5058 let data = build_minimal_ir(&["div", "x\"><script", "v", "ok-attr"], &[], &opcodes, &[]);
5059 let module = IrModule::parse(&data).unwrap();
5060 let slots = SlotData::new(0);
5061 let html = walk_to_html(&module, &slots).unwrap();
5062 assert_eq!(html, "<div ok-attr=\"v\">");
5063 }
5064
5065 #[test]
5066 fn walk_drops_hostile_boolean_attribute_name() {
5067 let opcodes = encode_open_tag(0, &[(1, 2)]);
5069 let data = build_minimal_ir(&["div", "y\"><script", ""], &[], &opcodes, &[]);
5070 let module = IrModule::parse(&data).unwrap();
5071 let slots = SlotData::new(0);
5072 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
5073 }
5074
5075 #[test]
5076 fn walk_drops_hostile_dyn_attr_name() {
5077 let mut opcodes = encode_open_tag(0, &[]);
5078 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5079 let data = build_minimal_ir(
5080 &["div", "z\"><script", "val"],
5081 &[(0, 2, 0x01, 0x00, &[])],
5082 &opcodes,
5083 &[],
5084 );
5085 let module = IrModule::parse(&data).unwrap();
5086 let mut slots = SlotData::new(1);
5087 slots.set(0, SlotValue::Text("x".into()));
5088 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
5089 }
5090
5091 #[test]
5094 fn walk_drops_javascript_url_in_dyn_attr() {
5095 let mut opcodes = encode_open_tag(0, &[]);
5096 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5097 let data = build_minimal_ir(
5098 &["a", "href", "url"],
5099 &[(0, 2, 0x01, 0x00, &[])],
5100 &opcodes,
5101 &[],
5102 );
5103 let module = IrModule::parse(&data).unwrap();
5104
5105 for payload in [
5108 "javascript:alert(1)",
5109 "JaVaScRiPt:alert(1)",
5110 "java\tscript:alert(1)",
5111 " javascript:alert(1)",
5112 "vbscript:msgbox(1)",
5113 "data:text/html,<script>alert(1)</script>",
5114 ] {
5115 let mut slots = SlotData::new(1);
5116 slots.set(0, SlotValue::Text(payload.to_string()));
5117 let html = walk_to_html(&module, &slots).unwrap();
5118 assert_eq!(html, "<a>", "payload {payload:?} was not dropped");
5121 }
5122 }
5123
5124 #[test]
5125 fn walk_keeps_safe_urls_and_data_images() {
5126 let mut opcodes = encode_open_tag(0, &[]);
5127 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5128 let data = build_minimal_ir(
5129 &["img", "src", "url"],
5130 &[(0, 2, 0x01, 0x00, &[])],
5131 &opcodes,
5132 &[],
5133 );
5134 let module = IrModule::parse(&data).unwrap();
5135
5136 for payload in [
5137 "/about",
5138 "https://example.com/x?a=1",
5139 "data:image/png;base64,AAAA",
5140 "data:image/svg+xml,%3Csvg%3E%3C/svg%3E",
5144 ] {
5145 let mut slots = SlotData::new(1);
5146 slots.set(0, SlotValue::Text(payload.to_string()));
5147 let html = walk_to_html(&module, &slots).unwrap();
5148 assert!(html.contains("src="), "payload {payload:?} was dropped");
5149 }
5150 }
5151
5152 #[test]
5153 fn walk_blocks_svg_data_url_outside_image_context() {
5154 let mut opcodes = encode_open_tag(0, &[]);
5155 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5156 let data = build_minimal_ir(
5157 &["a", "href", "url"],
5158 &[(0, 2, 0x01, 0x00, &[])],
5159 &opcodes,
5160 &[],
5161 );
5162 let module = IrModule::parse(&data).unwrap();
5163 let mut slots = SlotData::new(1);
5164 slots.set(
5165 0,
5166 SlotValue::Text("data:image/svg+xml,%3Csvg onload=alert(1)%3E".into()),
5167 );
5168 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<a>");
5171 }
5172
5173 #[test]
5174 fn walk_drops_event_handler_attributes() {
5175 let opcodes = encode_open_tag(0, &[(1, 2)]);
5177 let data = build_minimal_ir(&["div", "onmouseover", "alert(1)"], &[], &opcodes, &[]);
5178 let module = IrModule::parse(&data).unwrap();
5179 assert_eq!(
5180 walk_to_html(&module, &SlotData::new(0)).unwrap(),
5181 "<div>",
5182 "static on* attribute must be dropped"
5183 );
5184
5185 let mut opcodes = encode_open_tag(0, &[]);
5187 opcodes.extend_from_slice(&encode_dyn_attr(1, 0));
5188 let data = build_minimal_ir(
5189 &["div", "ONCLICK", "h"],
5190 &[(0, 2, 0x01, 0x00, &[])],
5191 &opcodes,
5192 &[],
5193 );
5194 let module = IrModule::parse(&data).unwrap();
5195 let mut slots = SlotData::new(1);
5196 slots.set(0, SlotValue::Text("alert(1)".into()));
5197 assert_eq!(walk_to_html(&module, &slots).unwrap(), "<div>");
5198 }
5199
5200 #[test]
5201 fn walk_drops_javascript_url_in_static_attr() {
5202 let opcodes = encode_open_tag(0, &[(1, 2)]);
5206 let data = build_minimal_ir(&["a", "href", "javascript:alert(1)"], &[], &opcodes, &[]);
5207 let module = IrModule::parse(&data).unwrap();
5208 assert_eq!(walk_to_html(&module, &SlotData::new(0)).unwrap(), "<a>");
5209 }
5210
5211 #[test]
5212 fn walk_comment_cannot_self_close() {
5213 for text in ["><img src=x onerror=alert(1)>", "-><img src=x>"] {
5216 let opcodes = encode_comment(0);
5217 let data = build_minimal_ir(&[text], &[], &opcodes, &[]);
5218 let module = IrModule::parse(&data).unwrap();
5219 let html = walk_to_html(&module, &SlotData::new(0)).unwrap();
5220 assert!(
5221 html.starts_with("<!-- "),
5222 "comment must not start with a closing sequence, got: {html}"
5223 );
5224 }
5225 }
5226
5227 #[test]
5230 fn walk_switch_matches_on_js_number_text() {
5231 let opcodes = encode_switch(0, &[(1, &encode_text(2)), (3, &encode_text(4))]);
5234 let data = build_minimal_ir(
5235 &["n", "1e+21", "big", "1000", "small"],
5236 &[(0, 0, 0x03, 0x00, &[])],
5237 &opcodes,
5238 &[],
5239 );
5240 let module = IrModule::parse(&data).unwrap();
5241
5242 let mut slots = SlotData::new(1);
5243 slots.set(0, SlotValue::Number(1e21));
5244 assert!(walk_to_html(&module, &slots).unwrap().contains("big"));
5245
5246 let mut slots = SlotData::new(1);
5247 slots.set(0, SlotValue::Number(1000.0));
5248 assert!(walk_to_html(&module, &slots).unwrap().contains("small"));
5249 }
5250
5251 struct Page {
5264 strings: Vec<String>,
5265 ops: Vec<u8>,
5266 slots: Vec<(u8, SlotValue)>,
5267 islands: Vec<(u16, u32, u32)>,
5268 }
5269
5270 impl Page {
5271 fn new() -> Self {
5272 Page {
5273 strings: Vec::new(),
5274 ops: Vec::new(),
5275 slots: Vec::new(),
5276 islands: Vec::new(),
5277 }
5278 }
5279
5280 fn intern(&mut self, s: &str) -> u32 {
5281 let index = match self.strings.iter().position(|x| x == s) {
5282 Some(i) => i,
5283 None => {
5284 self.strings.push(s.to_string());
5285 self.strings.len() - 1
5286 }
5287 };
5288 u32::try_from(index).unwrap()
5289 }
5290
5291 fn slot(&mut self, value: SlotValue) -> u16 {
5293 let type_byte = match &value {
5294 SlotValue::Null | SlotValue::Text(_) => 0x01,
5295 SlotValue::Bool(_) => 0x02,
5296 SlotValue::Number(_) => 0x03,
5297 SlotValue::Array(_) => 0x04,
5298 SlotValue::Object(_) => 0x05,
5299 };
5300 self.slots.push((type_byte, value));
5301 u16::try_from(self.slots.len() - 1).unwrap()
5302 }
5303
5304 fn open(&mut self, tag: &str, attrs: &[(&str, &str)]) -> &mut Self {
5305 let tag = self.intern(tag);
5306 let attrs: Vec<(u32, u32)> = attrs
5307 .iter()
5308 .map(|(k, v)| (self.intern(k), self.intern(v)))
5309 .collect();
5310 self.ops.extend_from_slice(&encode_open_tag(tag, &attrs));
5311 self
5312 }
5313
5314 fn close(&mut self, tag: &str) -> &mut Self {
5315 let tag = self.intern(tag);
5316 self.ops.extend_from_slice(&encode_close_tag(tag));
5317 self
5318 }
5319
5320 fn text(&mut self, text: &str) -> &mut Self {
5321 let text = self.intern(text);
5322 self.ops.extend_from_slice(&encode_text(text));
5323 self
5324 }
5325
5326 fn dyn_text(&mut self, slot: u16) -> &mut Self {
5328 self.ops.extend_from_slice(&encode_dyn_text(slot, slot));
5329 self
5330 }
5331
5332 fn dyn_attr(&mut self, name: &str, slot: u16) -> &mut Self {
5333 let name = self.intern(name);
5334 self.ops.extend_from_slice(&encode_dyn_attr(name, slot));
5335 self
5336 }
5337
5338 fn prop(&mut self, source: u16, name: &str, target: u16) -> &mut Self {
5340 let name = self.intern(name);
5341 self.ops
5342 .extend_from_slice(&encode_prop(source, name, target));
5343 self
5344 }
5345
5346 fn body(&mut self, f: impl FnOnce(&mut Self)) -> Vec<u8> {
5348 let outer = std::mem::take(&mut self.ops);
5349 f(self);
5350 std::mem::replace(&mut self.ops, outer)
5351 }
5352
5353 fn show_if(
5354 &mut self,
5355 slot: u16,
5356 then_ops: impl FnOnce(&mut Self),
5357 else_ops: impl FnOnce(&mut Self),
5358 ) -> &mut Self {
5359 let then_ops = self.body(then_ops);
5360 let else_ops = self.body(else_ops);
5361 self.ops
5362 .extend_from_slice(&encode_show_if(slot, &then_ops, &else_ops));
5363 self
5364 }
5365
5366 fn list(&mut self, slot: u16, item: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
5367 let body = self.body(body);
5368 self.ops.extend_from_slice(&encode_list(slot, item, &body));
5369 self
5370 }
5371
5372 fn island(&mut self, id: u16, body: impl FnOnce(&mut Self)) -> &mut Self {
5375 let name = self.intern(&format!("Island{id}"));
5376 let offset = u32::try_from(self.ops.len()).unwrap();
5377 self.islands.push((id, name, offset));
5378 self.ops.extend_from_slice(&encode_island_start(id));
5379 body(self);
5380 self.ops.extend_from_slice(&encode_island_end(id));
5381 self
5382 }
5383
5384 fn module_and_slots(&self) -> (IrModule, SlotData) {
5385 let mut strings = self.strings.clone();
5386 let mut decls = Vec::new();
5387 for (id, (type_byte, _)) in self.slots.iter().enumerate() {
5388 strings.push(format!("slot{id}"));
5389 let name = u32::try_from(strings.len() - 1).unwrap();
5390 decls.push((u16::try_from(id).unwrap(), name, *type_byte, 0x00, &[][..]));
5391 }
5392 let islands: Vec<(u16, u8, u8, u32, u32, &[u16])> = self
5393 .islands
5394 .iter()
5395 .map(|&(id, name, offset)| (id, 0x01, 0x01, name, offset, &[][..]))
5396 .collect();
5397 let strings: Vec<&str> = strings.iter().map(String::as_str).collect();
5398 let data = build_minimal_ir(&strings, &decls, &self.ops, &islands);
5399 let module = IrModule::parse(&data).unwrap();
5400 let mut slots = SlotData::new(self.slots.len());
5401 for (id, (_, value)) in self.slots.iter().enumerate() {
5402 slots.set(u16::try_from(id).unwrap(), value.clone());
5403 }
5404 (module, slots)
5405 }
5406
5407 fn render(&self) -> String {
5408 let (module, slots) = self.module_and_slots();
5409 walk_to_html(&module, &slots).unwrap()
5410 }
5411
5412 fn render_island(&self, id: u16) -> String {
5413 let (module, slots) = self.module_and_slots();
5414 walk_island(&module, &slots, id).unwrap()
5415 }
5416 }
5417
5418 fn text(s: &str) -> SlotValue {
5419 SlotValue::Text(s.to_string())
5420 }
5421
5422 #[test]
5423 fn dyn_text_in_a_text_only_parent_is_written_without_markers() {
5424 for parent in ["textarea", "title", "style", "script", "TEXTAREA"] {
5429 let mut page = Page::new();
5430 let inside = page.slot(text("a<b&c"));
5431 let outside = page.slot(text("world"));
5432 page.open("div", &[])
5433 .open(parent, &[])
5434 .dyn_text(inside)
5435 .close(parent)
5436 .open("p", &[])
5437 .dyn_text(outside)
5438 .close("p")
5439 .close("div");
5440 assert_eq!(
5441 page.render(),
5442 format!(
5443 "<div><{parent}>a<b&c</{parent}>\
5444 <p><!--f:t1-->world<!--/f:t1--></p></div>"
5445 ),
5446 "inside <{parent}>"
5447 );
5448 }
5449 }
5450
5451 #[test]
5452 fn dyn_text_outside_the_text_only_parents_keeps_its_markers() {
5453 for parent in ["pre", "option", "span", "titles", "xtextarea", "noscript"] {
5456 let mut page = Page::new();
5457 let value = page.slot(text("hello"));
5458 page.open(parent, &[]).dyn_text(value).close(parent);
5459 assert_eq!(
5460 page.render(),
5461 format!("<{parent}><!--f:t0-->hello<!--/f:t0--></{parent}>"),
5462 "inside <{parent}>"
5463 );
5464 }
5465 }
5466
5467 #[test]
5468 fn an_empty_value_in_a_text_only_parent_writes_nothing() {
5469 let mut page = Page::new();
5472 let inside = page.slot(text(""));
5473 let outside = page.slot(text(""));
5474 page.open("textarea", &[])
5475 .dyn_text(inside)
5476 .close("textarea")
5477 .open("p", &[])
5478 .dyn_text(outside)
5479 .close("p");
5480 assert_eq!(
5481 page.render(),
5482 "<textarea></textarea><p><!--f:t1-->\u{200B}<!--/f:t1--></p>"
5483 );
5484 }
5485
5486 #[test]
5487 fn a_textarea_doubles_a_leading_line_feed() {
5488 fn textarea(build: impl FnOnce(&mut Page)) -> String {
5492 let mut page = Page::new();
5493 page.open("textarea", &[]);
5494 build(&mut page);
5495 page.close("textarea");
5496 page.render()
5497 }
5498 let cases: [(&str, String, &str); 5] = [
5499 (
5500 "a dynamic value",
5501 textarea(|p| {
5502 let v = p.slot(text("\nhello"));
5503 p.dyn_text(v);
5504 }),
5505 "<textarea>\n\nhello</textarea>",
5506 ),
5507 (
5508 "a CRLF value",
5509 textarea(|p| {
5510 let v = p.slot(text("\r\nhello"));
5511 p.dyn_text(v);
5512 }),
5513 "<textarea>\n\r\nhello</textarea>",
5514 ),
5515 (
5516 "an empty value first: the first written byte decides",
5517 textarea(|p| {
5518 let empty = p.slot(text(""));
5519 let v = p.slot(text("\nx"));
5520 p.dyn_text(empty).dyn_text(v);
5521 }),
5522 "<textarea>\n\nx</textarea>",
5523 ),
5524 (
5525 "an LF after other content is kept as is",
5526 textarea(|p| {
5527 let v = p.slot(text("\nb"));
5528 p.text("a").dyn_text(v);
5529 }),
5530 "<textarea>a\nb</textarea>",
5531 ),
5532 (
5533 "static text",
5534 textarea(|p| {
5535 p.text("\nstatic");
5536 }),
5537 "<textarea>\n\nstatic</textarea>",
5538 ),
5539 ];
5540 for (case, html, expected) in cases {
5541 assert_eq!(html, expected, "{case}");
5542 }
5543
5544 let mut page = Page::new();
5546 let v = page.slot(text("\nhello"));
5547 page.open("title", &[]).dyn_text(v).close("title");
5548 assert_eq!(page.render(), "<title>\nhello</title>");
5549 }
5550
5551 #[test]
5552 fn no_marker_kind_is_written_inside_a_text_only_parent() {
5553 let mut page = Page::new();
5556 let loading = page.slot(SlotValue::Bool(false));
5557 let name = page.slot(text("Ada"));
5558 let tags = page.slot(SlotValue::array(vec![text("x"), text("y")]));
5559 let tag = page.slot(SlotValue::Null);
5560 page.open("title", &[])
5561 .show_if(
5562 loading,
5563 |p| {
5564 p.text("Loading");
5565 },
5566 |p| {
5567 p.dyn_text(name);
5568 },
5569 )
5570 .text(" | ")
5571 .list(tags, tag, |p| {
5572 p.dyn_text(tag).text(";");
5573 })
5574 .close("title")
5575 .open("p", &[])
5576 .show_if(
5577 loading,
5578 |p| {
5579 p.text("Loading");
5580 },
5581 |p| {
5582 p.dyn_text(name);
5583 },
5584 )
5585 .close("p");
5586 assert_eq!(
5587 page.render(),
5588 "<title>Ada | x;y;</title>\
5589 <p><!--f:s0--><!--f:t1-->Ada<!--/f:t1--><!--/f:s0--></p>"
5590 );
5591
5592 let mut page = Page::new();
5594 page.open("textarea", &[])
5595 .island(0, |p| {
5596 p.text("x");
5597 })
5598 .close("textarea")
5599 .island(1, |p| {
5600 p.open("div", &[]).close("div");
5601 });
5602 assert_eq!(
5603 page.render(),
5604 "<textarea>x</textarea><!--f:i1--><div data-forma-island=\"1\" \
5605 data-forma-component=\"Island1\" data-forma-status=\"pending\" \
5606 data-forma-hydrate=\"load\"></div><!--/f:i1-->"
5607 );
5608 }
5609
5610 #[test]
5611 fn a_textarea_value_binding_is_written_as_its_text() {
5612 let cases = [
5616 (text("hello"), "hello"),
5617 (
5618 text("</textarea><script>x</script>"),
5619 "</textarea><script>x</script>",
5620 ),
5621 (text("\nhello"), "\n\nhello"),
5622 (SlotValue::Null, ""),
5623 (SlotValue::Number(0.0), "0"),
5624 (SlotValue::Bool(false), "false"),
5625 ];
5626 for (value, inner) in cases {
5627 let mut page = Page::new();
5628 let v = page.slot(value.clone());
5629 let note = page.slot(text("note"));
5630 page.open("textarea", &[("name", "t")])
5631 .dyn_attr("value", v)
5632 .close("textarea")
5633 .open("p", &[])
5634 .dyn_text(note)
5635 .close("p");
5636 assert_eq!(
5637 page.render(),
5638 format!(
5639 "<textarea name=\"t\">{inner}</textarea><p><!--f:t1-->note<!--/f:t1--></p>"
5640 ),
5641 "value {value:?}"
5642 );
5643 }
5644
5645 let mut page = Page::new();
5647 page.open("textarea", &[("name", "t"), ("value", "hello")])
5648 .close("textarea");
5649 assert_eq!(page.render(), "<textarea name=\"t\">hello</textarea>");
5650 }
5651
5652 #[test]
5653 fn a_textarea_value_binding_wins_over_its_children() {
5654 let mut page = Page::new();
5657 let v = page.slot(text("bound"));
5658 let child = page.slot(text("child"));
5659 page.open("textarea", &[])
5660 .dyn_attr("value", v)
5661 .dyn_text(child)
5662 .text(" default")
5663 .close("textarea")
5664 .open("p", &[])
5665 .dyn_text(child)
5666 .close("p");
5667 assert_eq!(
5668 page.render(),
5669 "<textarea>bound</textarea><p><!--f:t1-->child<!--/f:t1--></p>"
5670 );
5671 }
5672
5673 fn select_page(value: SlotValue) -> String {
5676 let mut page = Page::new();
5677 let v = page.slot(value);
5678 page.open("select", &[("name", "c")]).dyn_attr("value", v);
5679 for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
5680 page.open("option", &[("value", value)])
5681 .text(label)
5682 .close("option");
5683 }
5684 page.close("select");
5685 page.render()
5686 }
5687
5688 fn option(value: &str, label: &str, selected: bool) -> String {
5689 let selected = if selected { " selected" } else { "" };
5690 format!("<option value=\"{value}\"{selected}>{label}</option>")
5691 }
5692
5693 #[test]
5694 fn a_select_value_binding_selects_the_matching_option() {
5695 for (value, chosen) in [("a", "a"), ("b", "b"), ("c", "c"), ("z", "")] {
5698 let expected = format!(
5699 "<select name=\"c\">{}{}{}</select>",
5700 option("a", "Apple", chosen == "a"),
5701 option("b", "Banana", chosen == "b"),
5702 option("c", "Cherry", chosen == "c"),
5703 );
5704 assert_eq!(select_page(text(value)), expected, "value {value}");
5705 }
5706 }
5707
5708 #[test]
5709 fn a_select_value_binding_compares_as_the_client_string() {
5710 for (value, chosen) in [(SlotValue::Number(2.0), "2"), (SlotValue::Null, "")] {
5712 let mut page = Page::new();
5713 let v = page.slot(value.clone());
5714 page.open("select", &[]).dyn_attr("value", v);
5715 for (value, label) in [("", "Choose"), ("1", "One"), ("2", "Two")] {
5716 page.open("option", &[("value", value)])
5717 .text(label)
5718 .close("option");
5719 }
5720 page.close("select");
5721 let placeholder = if chosen.is_empty() {
5723 "<option value selected>Choose</option>"
5724 } else {
5725 "<option value>Choose</option>"
5726 };
5727 let expected = format!(
5728 "<select>{placeholder}{}{}</select>",
5729 option("1", "One", chosen == "1"),
5730 option("2", "Two", chosen == "2"),
5731 );
5732 assert_eq!(page.render(), expected, "value {value:?}");
5733 }
5734 }
5735
5736 #[test]
5737 fn a_select_value_binding_matches_an_option_without_a_value_by_its_text() {
5738 let render = |value: &str| {
5741 let mut page = Page::new();
5742 let v = page.slot(text(value));
5743 let label = page.slot(text("Blue"));
5744 page.open("select", &[])
5745 .dyn_attr("value", v)
5746 .open("option", &[])
5747 .text("Red")
5748 .close("option")
5749 .open("option", &[])
5750 .text(" Dark \n green ")
5751 .close("option")
5752 .open("option", &[])
5753 .dyn_text(label)
5754 .close("option")
5755 .close("select");
5756 page.render()
5757 };
5758 assert_eq!(
5759 render("Dark green"),
5760 "<select><option>Red</option><option selected> Dark \n green </option>\
5761 <option><!--f:t1-->Blue<!--/f:t1--></option></select>"
5762 );
5763 assert_eq!(
5764 render("Blue"),
5765 "<select><option>Red</option><option> Dark \n green </option>\
5766 <option selected><!--f:t1-->Blue<!--/f:t1--></option></select>"
5767 );
5768 }
5769
5770 #[test]
5771 fn a_select_value_binding_reaches_options_rendered_by_a_list() {
5772 let short: Vec<(String, String)> = [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")]
5777 .iter()
5778 .map(|(id, label)| (id.to_string(), label.to_string()))
5779 .collect();
5780 let long: Vec<(String, String)> = (0..45)
5781 .map(|i| (format!("r{i}"), format!("Row {i}")))
5782 .collect();
5783 let cases: [(&[(String, String)], &str, usize); 4] = [
5784 (&short, "a", 0),
5785 (&short, "c", 2),
5786 (&long, "r40", 40),
5787 (&long, "r44", 44),
5788 ];
5789 for (rows_in, value, chosen) in cases {
5790 let mut page = Page::new();
5791 let v = page.slot(text(value));
5792 let rows = rows_in
5793 .iter()
5794 .map(|(id, label)| {
5795 SlotValue::object(vec![
5796 ("id".to_string(), text(id)),
5797 ("label".to_string(), text(label)),
5798 ])
5799 })
5800 .collect();
5801 let items = page.slot(SlotValue::array(rows));
5802 let item = page.slot(SlotValue::Null);
5803 let id = page.slot(SlotValue::Null);
5804 let label = page.slot(SlotValue::Null);
5805 page.open("select", &[])
5806 .dyn_attr("value", v)
5807 .list(items, item, |p| {
5808 p.prop(item, "id", id)
5809 .prop(item, "label", label)
5810 .open("option", &[])
5811 .dyn_attr("value", id)
5812 .dyn_text(label)
5813 .close("option");
5814 })
5815 .close("select");
5816 let expected: String = rows_in
5817 .iter()
5818 .enumerate()
5819 .map(|(i, (id, label))| {
5820 let selected = if i == chosen { " selected" } else { "" };
5821 format!(
5822 "<option value=\"{id}\"{selected}><!--f:t4-->{label}<!--/f:t4--></option>"
5823 )
5824 })
5825 .collect();
5826 assert_eq!(
5827 page.render(),
5828 format!("<select><!--f:l1-->{expected}<!--/f:l1--></select>"),
5829 "value {value}"
5830 );
5831 }
5832 }
5833
5834 #[test]
5835 fn a_select_binding_ends_with_its_select() {
5836 let mut page = Page::new();
5841 let v = page.slot(text("b"));
5842 page.open("select", &[])
5843 .dyn_attr("value", v)
5844 .open("option", &[("value", "a")])
5845 .text("A")
5846 .close("option")
5847 .close("select")
5848 .open("datalist", &[])
5849 .open("option", &[("value", "b")])
5850 .text("B")
5851 .close("option")
5852 .close("datalist");
5853 assert_eq!(
5854 page.render(),
5855 format!(
5856 "<select>{}</select><datalist>{}</datalist>",
5857 option("a", "A", false),
5858 option("b", "B", false)
5859 )
5860 );
5861 }
5862
5863 #[test]
5864 fn an_options_first_value_attribute_is_its_value() {
5865 for (bound, selected) in [("a", " selected"), ("b", "")] {
5868 let mut page = Page::new();
5869 let v = page.slot(text(bound));
5870 let second = page.slot(text("b"));
5871 page.open("select", &[])
5872 .dyn_attr("value", v)
5873 .open("option", &[("value", "a")])
5874 .dyn_attr("value", second)
5875 .text("Only")
5876 .close("option")
5877 .close("select");
5878 assert_eq!(
5879 page.render(),
5880 format!("<select><option value=\"a\" value=\"b\"{selected}>Only</option></select>"),
5881 "bound to {bound}"
5882 );
5883 }
5884 }
5885
5886 #[test]
5887 fn multiple_counts_only_when_the_attribute_is_written() {
5888 for (multiple, attr, chosen) in [(false, "", false), (true, " multiple", true)] {
5893 let mut page = Page::new();
5894 let flag = page.slot(SlotValue::Bool(multiple));
5895 let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
5896 page.open("select", &[])
5897 .dyn_attr("multiple", flag)
5898 .dyn_attr("value", v);
5899 for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
5900 page.open("option", &[("value", value)])
5901 .text(label)
5902 .close("option");
5903 }
5904 page.close("select");
5905 assert_eq!(
5906 page.render(),
5907 format!(
5908 "<select{attr}>{}{}{}</select>",
5909 option("a", "Apple", chosen),
5910 option("b", "Banana", false),
5911 option("c", "Cherry", chosen)
5912 ),
5913 "multiple {multiple}"
5914 );
5915 }
5916 }
5917
5918 #[test]
5919 fn a_null_member_of_a_multiple_value_compares_as_null() {
5920 let mut page = Page::new();
5923 let v = page.slot(SlotValue::array(vec![SlotValue::Null, text("b")]));
5924 page.open("select", &[("multiple", "")])
5925 .dyn_attr("value", v);
5926 for (value, label) in [("null", "Null"), ("x", "Empty"), ("b", "Banana")] {
5927 page.open("option", &[("value", value)])
5928 .text(label)
5929 .close("option");
5930 }
5931 page.close("select");
5932 assert_eq!(
5936 page.render(),
5937 format!(
5938 "<select multiple>{}{}{}</select>",
5939 option("null", "Null", true),
5940 option("x", "Empty", false),
5941 option("b", "Banana", true)
5942 )
5943 );
5944 }
5945
5946 #[test]
5947 fn an_option_ends_at_the_next_option_or_optgroup() {
5948 let mut page = Page::new();
5952 let v = page.slot(text("A"));
5953 page.open("select", &[])
5954 .dyn_attr("value", v)
5955 .open("option", &[])
5956 .text("A")
5957 .open("option", &[])
5958 .text("B")
5959 .close("select");
5960 assert_eq!(
5961 page.render(),
5962 "<select><option selected>A<option>B</select>",
5963 "ended by <option>"
5964 );
5965
5966 let mut page = Page::new();
5967 let v = page.slot(text("A"));
5968 page.open("select", &[])
5969 .dyn_attr("value", v)
5970 .open("option", &[])
5971 .text("A")
5972 .open("optgroup", &[("label", "g")])
5973 .open("option", &[("value", "x")])
5974 .text("X")
5975 .close("option")
5976 .close("optgroup")
5977 .close("select");
5978 assert_eq!(
5979 page.render(),
5980 "<select><option selected>A<optgroup label=\"g\">\
5981 <option value=\"x\">X</option></optgroup></select>",
5982 "ended by <optgroup>"
5983 );
5984 }
5985
5986 #[test]
5987 fn a_single_select_selects_only_the_first_matching_option() {
5988 let mut page = Page::new();
5991 let v = page.slot(text("b"));
5992 page.open("select", &[])
5993 .dyn_attr("value", v)
5994 .open("option", &[("value", "b")])
5995 .text("First")
5996 .close("option")
5997 .open("option", &[("value", "b")])
5998 .text("Second")
5999 .close("option")
6000 .close("select");
6001 assert_eq!(
6002 page.render(),
6003 format!(
6004 "<select>{}{}</select>",
6005 option("b", "First", true),
6006 option("b", "Second", false)
6007 )
6008 );
6009 }
6010
6011 #[test]
6012 fn a_multiple_select_selects_every_option_in_an_array_value() {
6013 let render = |attrs: &[(&str, &str)]| {
6017 let mut page = Page::new();
6018 let v = page.slot(SlotValue::array(vec![text("a"), text("c")]));
6019 page.open("select", attrs).dyn_attr("value", v);
6020 for (value, label) in [("a", "Apple"), ("b", "Banana"), ("c", "Cherry")] {
6021 page.open("option", &[("value", value)])
6022 .text(label)
6023 .close("option");
6024 }
6025 page.close("select");
6026 page.render()
6027 };
6028 assert_eq!(
6029 render(&[("multiple", "")]),
6030 format!(
6031 "<select multiple>{}{}{}</select>",
6032 option("a", "Apple", true),
6033 option("b", "Banana", false),
6034 option("c", "Cherry", true)
6035 )
6036 );
6037 assert_eq!(
6038 render(&[]),
6039 format!(
6040 "<select>{}{}{}</select>",
6041 option("a", "Apple", false),
6042 option("b", "Banana", false),
6043 option("c", "Cherry", false)
6044 )
6045 );
6046 }
6047
6048 #[test]
6049 fn a_bound_select_overrides_authored_selected_on_its_options() {
6050 let render = |bound: bool| {
6053 let mut page = Page::new();
6054 let v = page.slot(text("a"));
6055 let on = page.slot(SlotValue::Bool(true));
6056 page.open("select", &[]);
6057 if bound {
6058 page.dyn_attr("value", v);
6059 }
6060 page.open("option", &[("value", "a")])
6061 .text("A")
6062 .close("option")
6063 .open("option", &[("value", "b"), ("selected", "")])
6064 .text("B")
6065 .close("option")
6066 .open("option", &[("value", "c")])
6067 .dyn_attr("selected", on)
6068 .text("C")
6069 .close("option")
6070 .close("select");
6071 page.render()
6072 };
6073 assert_eq!(
6074 render(true),
6075 format!(
6076 "<select>{}{}{}</select>",
6077 option("a", "A", true),
6078 option("b", "B", false),
6079 option("c", "C", false)
6080 )
6081 );
6082 assert_eq!(
6083 render(false),
6084 format!(
6085 "<select>{}{}{}</select>",
6086 option("a", "A", false),
6087 option("b", "B", true),
6088 option("c", "C", true)
6089 )
6090 );
6091 }
6092
6093 #[test]
6094 fn a_static_select_value_attribute_selects_its_option() {
6095 let mut page = Page::new();
6096 page.open("select", &[("value", "b")])
6097 .open("option", &[("value", "a")])
6098 .text("A")
6099 .close("option")
6100 .open("option", &[("value", "b")])
6101 .text("B")
6102 .close("option")
6103 .close("select");
6104 assert_eq!(
6105 page.render(),
6106 format!(
6107 "<select>{}{}</select>",
6108 option("a", "A", false),
6109 option("b", "B", true)
6110 )
6111 );
6112 }
6113
6114 #[test]
6115 fn the_context_rules_hold_in_an_island_fragment() {
6116 let mut page = Page::new();
6119 let body = page.slot(text("hello"));
6120 let choice = page.slot(text("b"));
6121 page.island(0, |p| {
6122 p.open("form", &[])
6123 .open("textarea", &[])
6124 .dyn_text(body)
6125 .close("textarea")
6126 .open("select", &[])
6127 .dyn_attr("value", choice)
6128 .open("option", &[("value", "a")])
6129 .text("A")
6130 .close("option")
6131 .open("option", &[("value", "b")])
6132 .text("B")
6133 .close("option")
6134 .close("select")
6135 .close("form");
6136 });
6137 let inner = format!(
6138 "<textarea>hello</textarea><select>{}{}</select>",
6139 option("a", "A", false),
6140 option("b", "B", true)
6141 );
6142 let full = page.render();
6143 let fragment = page.render_island(0);
6144 for (path, html) in [("walk_to_html", &full), ("walk_island", &fragment)] {
6145 assert!(html.contains(&inner), "{path}: {html}");
6146 }
6147 assert!(
6148 full.starts_with("<!--f:i0--><form data-forma-island=\"0\""),
6149 "{full}"
6150 );
6151 }
6152}