1use noxid_dom_ir::{DomAttribute, DomNode};
2use noxid_ir::*;
3use noxid_source::{html_escape, js_escape};
4use std::collections::BTreeSet;
5
6const LANGUAGE_VALUE_EQUALITY_FUNCTION: &str = r#"function $noxEqual($noxLeft, $noxRight) {
7 if ($noxLeft === $noxRight) return true;
8 if (Array.isArray($noxLeft) || Array.isArray($noxRight)) return Array.isArray($noxLeft) && Array.isArray($noxRight) && $noxLeft.length === $noxRight.length && $noxLeft.every(($noxValue, $noxIndex) => $noxEqual($noxValue, $noxRight[$noxIndex]));
9 if ($noxLeft === null || $noxRight === null || typeof $noxLeft !== "object" || typeof $noxRight !== "object") return false;
10 const $noxLeftKeys = Object.keys($noxLeft).sort();
11 const $noxRightKeys = Object.keys($noxRight).sort();
12 return $noxLeftKeys.length === $noxRightKeys.length && $noxLeftKeys.every(($noxKey, $noxIndex) => $noxKey === $noxRightKeys[$noxIndex] && $noxEqual($noxLeft[$noxKey], $noxRight[$noxKey]));
13}
14"#;
15
16#[derive(Clone, Debug)]
17pub struct GeneratedComponent {
18 pub javascript: String,
19 pub css: String,
20 pub runtime_imports: BTreeSet<String>,
23 pub modules: Vec<GeneratedModule>,
26}
27
28#[derive(Clone, Debug)]
29pub struct GeneratedModule {
30 pub component: String,
31 pub javascript: String,
32 pub runtime_imports: BTreeSet<String>,
33}
34
35#[derive(Clone, Debug)]
36struct SlotChildren {
37 template_name: String,
38 operations: Vec<Operation>,
39}
40
41#[derive(Clone, Debug)]
42enum Operation {
43 Text {
44 id: SemanticId,
45 marker: usize,
46 expression: SemanticExpr,
47 dependencies: Vec<SemanticId>,
48 },
49 Attribute {
50 id: SemanticId,
51 marker: usize,
52 name: String,
53 expression: SemanticExpr,
54 dependencies: Vec<SemanticId>,
55 },
56 TwoWayBinding {
57 id: SemanticId,
58 marker: usize,
59 name: String,
60 target: SemanticId,
61 },
62 Event {
63 id: SemanticId,
64 marker: usize,
65 event: String,
66 action: SemanticId,
67 arguments: Option<Vec<SemanticExpr>>,
68 },
69 RoutePrefetch {
70 marker: usize,
71 trigger: PrefetchTrigger,
72 },
73 Attachment {
74 attachment: ElementAttachment,
75 marker: usize,
76 },
77 Component {
78 id: SemanticId,
79 marker: usize,
80 target: SemanticId,
81 props: Vec<PropArgument>,
82 handlers: Vec<ComponentEventHandler>,
83 prefetch: Option<PrefetchTrigger>,
84 children: Option<SlotChildren>,
85 },
86 Slot {
87 id: SemanticId,
88 marker: usize,
89 },
90 Conditional {
91 id: SemanticId,
92 marker: usize,
93 condition: SemanticExpr,
94 dependencies: Vec<SemanticId>,
95 transition: Option<ConditionalTransition>,
96 template_name: String,
97 operations: Vec<Operation>,
98 },
99 Match {
100 id: SemanticId,
101 marker: usize,
102 expression: SemanticExpr,
103 dependencies: Vec<SemanticId>,
104 cases: Vec<MatchOperationCase>,
105 },
106 Stream {
107 id: SemanticId,
108 marker: usize,
109 expression: SemanticExpr,
110 dependencies: Vec<SemanticId>,
111 cases: Vec<MatchOperationCase>,
112 },
113 For {
114 id: SemanticId,
115 marker: usize,
116 collection: SemanticExpr,
117 dependencies: Vec<SemanticId>,
118 binding: ForBinding,
119 key: Box<SemanticExpr>,
120 template_name: String,
121 operations: Vec<Operation>,
122 },
123}
124
125#[derive(Clone, Debug)]
126struct MatchOperationCase {
127 variant: String,
128 binding: Option<MatchBinding>,
129 template_name: String,
130 operations: Vec<Operation>,
131}
132
133#[derive(Clone, Debug, Default)]
134struct Template {
135 html: String,
136 operations: Vec<Operation>,
137}
138
139struct TemplateBuilder<'a> {
140 scope: &'a str,
141 component: &'a str,
142 marker: usize,
143 conditional: usize,
144 match_block: usize,
145 stream_block: usize,
146 for_block: usize,
147 slot_children: usize,
148 declarations: &'a mut Vec<(String, String)>,
149}
150
151pub fn generate(component: &ComponentDefinition) -> Result<GeneratedComponent, String> {
152 generate_program(&SemanticProgram {
153 imports: vec![],
154 functions: vec![],
155 external_modules: vec![],
156 contexts: vec![],
157 types: vec![],
158 distinct_types: vec![],
159 resources: vec![],
160 streams: vec![],
161 agents: vec![],
162 endpoints: vec![],
163 tasks: vec![],
164 queues: vec![],
165 models: vec![],
166 components: vec![component.clone()],
167 })
168}
169
170pub fn generate_program(program: &SemanticProgram) -> Result<GeneratedComponent, String> {
171 generate_program_with_resource_module(program, None)
172}
173
174pub fn generate_program_with_resource_module(
175 program: &SemanticProgram,
176 resource_module: Option<&str>,
177) -> Result<GeneratedComponent, String> {
178 generate_program_with_modules(program, None, resource_module, None, None)
179}
180
181pub fn generate_program_with_modules(
182 program: &SemanticProgram,
183 validator_module: Option<&str>,
184 resource_module: Option<&str>,
185 stream_module: Option<&str>,
186 agent_module: Option<&str>,
187) -> Result<GeneratedComponent, String> {
188 let mut generated = generate_program_inner(
189 program,
190 validator_module,
191 resource_module,
192 stream_module,
193 agent_module,
194 )?;
195 prepend_external_component_imports(&mut generated.javascript, program);
196 if program.components.len() > 1 {
197 generated.modules = program
198 .components
199 .iter()
200 .map(|component| {
201 let single = SemanticProgram {
202 imports: program.imports.clone(),
203 functions: program.functions.clone(),
204 external_modules: program.external_modules.clone(),
205 contexts: program.contexts.clone(),
206 types: program.types.clone(),
207 distinct_types: program.distinct_types.clone(),
208 resources: program.resources.clone(),
209 streams: program.streams.clone(),
210 agents: program.agents.clone(),
211 endpoints: vec![],
212 tasks: vec![],
213 queues: vec![],
214 models: vec![],
215 components: vec![component.clone()],
216 };
217 let mut output = generate_program_inner(
218 &single,
219 validator_module,
220 resource_module,
221 stream_module,
222 agent_module,
223 )?;
224 prepend_external_component_imports(&mut output.javascript, &single);
225 let dependencies = invoked_components(&component.view);
226 let prefetched = prefetched_components(&component.view);
227 if !dependencies.is_empty() {
228 let prefix = dependencies
232 .iter()
233 .map(|name| {
234 component_import(
235 name,
236 supports_hydration(component),
237 prefetched.contains(name),
238 )
239 })
240 .collect::<String>();
241 output.javascript.insert_str(0, &prefix);
242 }
243 Ok(GeneratedModule {
244 component: component.name.clone(),
245 javascript: output.javascript,
246 runtime_imports: output.runtime_imports,
247 })
248 })
249 .collect::<Result<Vec<_>, String>>()?;
250 }
251 Ok(generated)
252}
253
254fn prepend_external_component_imports(javascript: &mut String, program: &SemanticProgram) {
255 let local = program
256 .components
257 .iter()
258 .map(|component| component.name.as_str())
259 .collect::<BTreeSet<_>>();
260 let imported = program
261 .imports
262 .iter()
263 .map(|import| import.name.as_str())
264 .collect::<BTreeSet<_>>();
265 let invoked = program
266 .components
267 .iter()
268 .flat_map(|component| invoked_components(&component.view))
269 .filter(|name| imported.contains(name.as_str()) && !local.contains(name.as_str()))
270 .collect::<BTreeSet<_>>();
271 let prefetched = program
272 .components
273 .iter()
274 .flat_map(|component| prefetched_components(&component.view))
275 .collect::<BTreeSet<_>>();
276 if invoked.is_empty() {
277 return;
278 }
279 let prefix = invoked
280 .iter()
281 .map(|name| {
282 component_import(
283 name,
284 program.components.iter().any(supports_hydration),
285 prefetched.contains(name),
286 )
287 })
288 .collect::<String>();
289 javascript.insert_str(0, &prefix);
290}
291
292fn supports_hydration(component: &ComponentDefinition) -> bool {
293 component.render.mode == ComponentRenderMode::Universal
294}
295
296fn component_import(name: &str, hydration: bool, prefetch: bool) -> String {
297 let hydrate = if hydration {
298 format!(", hydrate{name}")
299 } else {
300 String::new()
301 };
302 let prefetch = if prefetch {
303 format!(", __noxidPrefetch{name}")
304 } else {
305 String::new()
306 };
307 format!("import {{ mount{name}{hydrate}{prefetch} }} from \"./{name}.js\";\n")
308}
309
310fn generate_program_inner(
311 program: &SemanticProgram,
312 validator_module: Option<&str>,
313 resource_module: Option<&str>,
314 stream_module: Option<&str>,
315 agent_module: Option<&str>,
316) -> Result<GeneratedComponent, String> {
317 let mut declarations = Vec::new();
318 let mut compiled = Vec::new();
319 for component in &program.components {
320 let scope = noxid_css_ir::scope_id(component.id.as_str());
321 let dom = noxid_dom_ir::lower(component);
322 let mut builder = TemplateBuilder {
323 scope: &scope,
324 component: &component.name,
325 marker: 0,
326 conditional: 0,
327 match_block: 0,
328 stream_block: 0,
329 for_block: 0,
330 slot_children: 0,
331 declarations: &mut declarations,
332 };
333 let template = build_template(&dom.nodes, &mut builder);
334 let template_name = format!("{}Template", component.name);
335 declarations.push((template_name.clone(), template.html));
336 compiled.push((component, template_name, template.operations));
337 }
338
339 let mut runtime_imports = BTreeSet::from([
340 "componentEmitter".to_string(),
341 "createOwner".to_string(),
342 "disposeOwner".to_string(),
343 ]);
344 for (component, _, operations) in &compiled {
345 collect_component_runtime_imports(
346 component,
347 operations,
348 supports_hydration(component),
349 &mut runtime_imports,
350 );
351 }
352 if !program.functions.is_empty() {
353 runtime_imports.insert("toSource".into());
354 }
355 let mut js = format!(
356 "import {{ {} }} from \"./noxid-runtime.js\";\n",
357 runtime_imports
358 .iter()
359 .cloned()
360 .collect::<Vec<_>>()
361 .join(", ")
362 );
363 for module in &program.external_modules {
364 if !module.functions.is_empty() {
365 js.push_str(&format!(
366 "import {{ {} }} from \"{}\";\n",
367 module
368 .functions
369 .iter()
370 .map(|function| function.name.as_str())
371 .collect::<Vec<_>>()
372 .join(", "),
373 js_escape(&module.runtime_source)
374 ));
375 }
376 }
377 if !program.external_modules.is_empty() {
378 js.push_str(&external_validation_prelude(program));
379 }
380 if program
381 .components
382 .iter()
383 .any(|component| noxid_ir::component_uses_date_helpers(component, &program.functions))
384 {
385 js.push_str(noxid_ir::DATE_HELPERS_JS);
386 js.push('\n');
387 }
388 emit_function_definitions(&mut js, &program.functions)?;
389 let mut state_validators = program
390 .components
391 .iter()
392 .flat_map(|component| {
393 component
394 .states
395 .iter()
396 .filter_map(|state| state_validator_id(program, component, &state.ty))
397 })
398 .collect::<BTreeSet<_>>();
399 state_validators.extend(
400 program
401 .components
402 .iter()
403 .filter_map(|component| component.presence.as_ref())
404 .map(|presence| presence.record_type.to_string()),
405 );
406 if !state_validators.is_empty() {
407 let module = validator_module.expect("named state requires a generated validator module");
408 js.push_str(&format!(
409 "import {{ typeValidators as __noxidTypeValidators }} from \"./{}\";\n",
410 js_escape(module)
411 ));
412 }
413 let mut resource_names = program
414 .components
415 .iter()
416 .flat_map(|component| component.resources.iter())
417 .map(|resource| resource.resource_name.clone())
418 .collect::<std::collections::BTreeSet<_>>();
419 resource_names.extend(
420 program
421 .components
422 .iter()
423 .flat_map(|component| component.actions.iter())
424 .flat_map(|action| action.invalidation.resources.iter())
425 .map(|resource| resource_definition_name(program, resource)),
426 );
427 if !resource_names.is_empty() {
428 let module = resource_module.expect("resource acquisitions require a generated module");
429 js.push_str(&format!(
430 "import {{ {}, queryClient as __noxidQueryClient }} from \"./{}\";\n",
431 resource_names.into_iter().collect::<Vec<_>>().join(", "),
432 js_escape(module)
433 ));
434 }
435 let stream_names = program
436 .components
437 .iter()
438 .flat_map(|component| component.streams.iter())
439 .map(|stream| stream.stream_name.clone())
440 .collect::<std::collections::BTreeSet<_>>();
441 if !stream_names.is_empty() {
442 let module = stream_module.expect("stream acquisitions require a generated module");
443 js.push_str(&format!(
444 "import {{ {} }} from \"./{}\";\n",
445 stream_names.into_iter().collect::<Vec<_>>().join(", "),
446 js_escape(module)
447 ));
448 }
449 let agent_names = program
450 .components
451 .iter()
452 .flat_map(|component| component.agents.iter())
453 .map(|agent| agent.agent_name.clone())
454 .collect::<std::collections::BTreeSet<_>>();
455 if !agent_names.is_empty() {
456 let module = agent_module.expect("agent acquisitions require a generated module");
457 js.push_str(&format!(
458 "import {{ {} }} from \"./{}\";\n",
459 agent_names.into_iter().collect::<Vec<_>>().join(", "),
460 js_escape(module)
461 ));
462 }
463 js.push('\n');
464 for (name, html) in declarations {
465 js.push_str(&format!(
466 "const {name} = document.createElement(\"template\");\n{name}.innerHTML = \"{}\";\n\n",
467 js_escape(&html)
468 ));
469 }
470 for (component, template_name, operations) in compiled {
471 emit_component(&mut js, program, component, &template_name, &operations)?;
472 }
473 if js.contains("$noxEqual(") {
474 js.push('\n');
475 js.push_str(LANGUAGE_VALUE_EQUALITY_FUNCTION);
476 }
477 Ok(GeneratedComponent {
478 javascript: js,
479 css: String::new(),
480 runtime_imports,
481 modules: vec![],
482 })
483}
484
485fn state_validator_id(
486 program: &SemanticProgram,
487 component: &ComponentDefinition,
488 ty: &noxid_types::Type,
489) -> Option<String> {
490 let noxid_types::Type::Named(name) = ty else {
491 return None;
492 };
493 component
494 .types
495 .iter()
496 .find(|definition| definition.name == *name)
497 .map(|definition| definition.id.to_string())
498 .or_else(|| {
499 component
500 .machines
501 .iter()
502 .find(|machine| machine.name == *name)
503 .map(|machine| machine.id.to_string())
504 })
505 .or_else(|| {
506 program
507 .types
508 .iter()
509 .find(|definition| definition.name == *name)
510 .map(|definition| definition.id.to_string())
511 })
512}
513
514fn resource_definition_name(program: &SemanticProgram, id: &SemanticId) -> String {
515 program
516 .resources
517 .iter()
518 .find(|resource| resource.id == *id)
519 .map(|resource| resource.name.clone())
520 .unwrap_or_else(|| {
521 panic!("validated action invalidation references missing resource `{id}`")
522 })
523}
524
525fn invoked_components(nodes: &[SemanticViewNode]) -> BTreeSet<String> {
526 fn visit(nodes: &[SemanticViewNode], output: &mut BTreeSet<String>) {
527 for node in nodes {
528 match node {
529 SemanticViewNode::ComponentInvocation {
530 component,
531 children,
532 ..
533 } => {
534 if let Some(name) = component.as_str().strip_prefix("component:") {
535 output.insert(name.to_string());
536 }
537 visit(children, output);
538 }
539 SemanticViewNode::Slot { .. } => {}
540 SemanticViewNode::Element { children, .. }
541 | SemanticViewNode::Conditional { children, .. }
542 | SemanticViewNode::For { children, .. } => visit(children, output),
543 SemanticViewNode::Match { cases, .. } | SemanticViewNode::Stream { cases, .. } => {
544 for case in cases {
545 visit(&case.children, output);
546 }
547 }
548 SemanticViewNode::Text { .. } | SemanticViewNode::Binding { .. } => {}
549 }
550 }
551 }
552 let mut output = BTreeSet::new();
553 visit(nodes, &mut output);
554 output
555}
556
557fn prefetched_components(nodes: &[SemanticViewNode]) -> BTreeSet<String> {
558 fn visit(nodes: &[SemanticViewNode], output: &mut BTreeSet<String>) {
559 for node in nodes {
560 match node {
561 SemanticViewNode::ComponentInvocation {
562 component,
563 prefetch,
564 children,
565 ..
566 } => {
567 if prefetch.is_some()
568 && let Some(name) = component.as_str().strip_prefix("component:")
569 {
570 output.insert(name.to_string());
571 }
572 visit(children, output);
573 }
574 SemanticViewNode::Element { children, .. }
575 | SemanticViewNode::Conditional { children, .. }
576 | SemanticViewNode::For { children, .. } => visit(children, output),
577 SemanticViewNode::Match { cases, .. } | SemanticViewNode::Stream { cases, .. } => {
578 for case in cases {
579 visit(&case.children, output);
580 }
581 }
582 SemanticViewNode::Slot { .. }
583 | SemanticViewNode::Text { .. }
584 | SemanticViewNode::Binding { .. } => {}
585 }
586 }
587 }
588 let mut output = BTreeSet::new();
589 visit(nodes, &mut output);
590 output
591}
592
593fn collect_component_runtime_imports(
594 component: &ComponentDefinition,
595 operations: &[Operation],
596 hydration: bool,
597 imports: &mut BTreeSet<String>,
598) {
599 if !component.middleware.is_empty() || !component.capabilities.is_empty() {
600 imports.insert("authorizeComponent".into());
601 }
602 if !component.props.is_empty() {
603 imports.insert("propSource".into());
604 }
605 if component
606 .states
607 .iter()
608 .any(|state| matches!(state.ty, noxid_types::Type::Array(_)))
609 {
610 imports.insert("collectionSignal".into());
611 }
612 if component
613 .states
614 .iter()
615 .any(|state| !matches!(state.ty, noxid_types::Type::Array(_)))
616 {
617 imports.insert("signal".into());
618 }
619 if !component.computed.is_empty() {
620 imports.insert("computed".into());
621 }
622 if !component.resources.is_empty() {
623 imports.insert("acquireResource".into());
624 }
625 if component
626 .resources
627 .iter()
628 .any(|resource| !resource.refresh.is_empty())
629 {
630 imports.insert("attachResourceRefreshTriggers".into());
631 }
632 if component
633 .actions
634 .iter()
635 .any(|action| !action.invalidation.resources.is_empty())
636 {
637 imports.insert("invalidateCompiledResources".into());
638 }
639 if component.presence.is_some() {
640 imports.insert("acquirePresence".into());
641 }
642 if component.streams.iter().any(|stream| {
643 component
644 .presence
645 .as_ref()
646 .is_none_or(|presence| presence.stream != stream.stream)
647 }) {
648 imports.insert("acquireStream".into());
649 }
650 if !component.agents.is_empty() {
651 imports.insert("acquireAgent".into());
652 }
653 if !component.context_providers.is_empty() {
654 imports.insert("provideContext".into());
655 if component
656 .context_providers
657 .iter()
658 .flat_map(|provider| &provider.fields)
659 .any(|field| source_requires_computed(&field.value, &field.dependencies))
660 {
661 imports.insert("computed".into());
662 }
663 }
664 if !component.context_uses.is_empty() {
665 imports.insert("useContext".into());
666 }
667 if !component.actions.is_empty() {
668 imports.insert("runAction".into());
669 }
670 if component.lifecycle.is_some() {
671 imports.insert("registerLifecycle".into());
672 }
673 if !component.behaviors.is_empty() {
674 imports.insert("applyBehavior".into());
675 }
676 if !component.regions.is_empty() {
677 imports.insert("validateComponentRegion".into());
678 }
679 if component.actions.iter().any(|action| {
680 !action.parameters.is_empty() || statements_declare_locals(&action.statements)
681 }) || component
682 .effects
683 .iter()
684 .any(|effect| statements_declare_locals(&effect.statements))
685 {
686 imports.insert("toSource".into());
687 }
688 if component
689 .effects
690 .iter()
691 .any(|item| matches!(item.kind, ReactiveEffectKind::Effect))
692 {
693 imports.insert("effect".into());
694 imports.insert("batch".into());
695 }
696 if component
697 .effects
698 .iter()
699 .any(|item| matches!(item.kind, ReactiveEffectKind::Watch { .. }))
700 {
701 imports.insert("watch".into());
702 imports.insert("batch".into());
703 imports.insert("toSource".into());
704 }
705 if component.actions.iter().any(|action| {
706 noxid_ir::flatten_statements(&action.statements)
707 .iter()
708 .any(|statement| matches!(statement, SemanticStatement::Transition { .. }))
709 }) || component.effects.iter().any(|effect| {
710 noxid_ir::flatten_statements(&effect.statements)
711 .iter()
712 .any(|statement| matches!(statement, SemanticStatement::Transition { .. }))
713 }) {
714 imports.insert("transitionMachine".into());
715 }
716 collect_operation_runtime_imports(operations, hydration, imports);
717}
718
719fn collect_operation_runtime_imports(
720 operations: &[Operation],
721 hydration: bool,
722 imports: &mut BTreeSet<String>,
723) {
724 for operation in operations {
725 match operation {
726 Operation::Text { .. } => {
727 imports.insert("bindText".into());
728 if hydration {
729 imports.insert("hydrateText".into());
730 }
731 imports.insert("findMarker".into());
732 }
733 Operation::Attribute { .. } => {
734 imports.insert("bindAttribute".into());
735 }
736 Operation::TwoWayBinding { .. } => {
737 imports.insert("bindProperty".into());
738 }
739 Operation::Event { .. } => {
740 imports.insert("listen".into());
741 }
742 Operation::RoutePrefetch { .. } => {
743 imports.insert("attachCompiledPrefetch".into());
744 }
745 Operation::Attachment { .. } => {
746 imports.insert("installAnimateAttachment".into());
747 }
748 Operation::Slot { .. } => {
749 imports.insert("mountSlot".into());
750 if hydration {
751 imports.insert("hydrateSlot".into());
752 }
753 imports.insert("findMarker".into());
754 }
755 Operation::Component {
756 props,
757 prefetch,
758 children,
759 ..
760 } => {
761 imports.insert("mountComponent".into());
762 if hydration {
763 imports.insert("hydrateComponent".into());
764 }
765 imports.insert("findMarker".into());
766 if prefetch.is_some() {
767 imports.insert("attachCompiledPrefetch".into());
768 }
769 if let Some(children) = children {
770 collect_operation_runtime_imports(&children.operations, hydration, imports);
771 }
772 if props
773 .iter()
774 .any(|prop| source_requires_computed(&prop.expression, &prop.dependencies))
775 {
776 imports.insert("computed".into());
777 }
778 }
779 Operation::Conditional {
780 condition,
781 dependencies,
782 transition,
783 operations,
784 ..
785 } => {
786 imports.insert("mountIf".into());
787 if hydration {
788 imports.insert("hydrateIf".into());
789 }
790 imports.insert("findMarker".into());
791 if transition.is_some() {
792 imports.insert("createPresence".into());
793 }
794 if source_requires_computed(condition, dependencies) {
795 imports.insert("computed".into());
796 }
797 collect_operation_runtime_imports(operations, hydration, imports);
798 }
799 Operation::Match {
800 expression,
801 dependencies,
802 cases,
803 ..
804 } => {
805 imports.insert("mountMatch".into());
806 if hydration {
807 imports.insert("hydrateMatch".into());
808 }
809 imports.insert("findMarker".into());
810 if match_source_requires_computed(expression, dependencies) {
811 imports.insert("computed".into());
812 }
813 if cases.iter().any(|case| case.binding.is_some()) {
814 imports.insert("toSource".into());
815 }
816 for case in cases {
817 collect_operation_runtime_imports(&case.operations, hydration, imports);
818 }
819 }
820 Operation::Stream {
821 expression,
822 dependencies,
823 cases,
824 ..
825 } => {
826 imports.insert("mountStream".into());
827 if hydration {
828 imports.insert("hydrateStream".into());
829 }
830 imports.insert("findMarker".into());
831 if source_requires_computed(expression, dependencies) {
832 imports.insert("computed".into());
833 }
834 if cases.iter().any(|case| case.binding.is_some()) {
835 imports.insert("toSource".into());
836 }
837 for case in cases {
838 collect_operation_runtime_imports(&case.operations, hydration, imports);
839 }
840 }
841 Operation::For {
842 collection,
843 dependencies,
844 operations,
845 ..
846 } => {
847 imports.insert("mountFor".into());
848 if hydration {
849 imports.insert("hydrateFor".into());
850 }
851 imports.insert("findMarker".into());
852 imports.insert("toSource".into());
853 if source_requires_computed(collection, dependencies) {
854 imports.insert("computed".into());
855 }
856 collect_operation_runtime_imports(operations, hydration, imports);
857 }
858 }
859 }
860}
861
862fn source_requires_computed(expression: &SemanticExpr, dependencies: &[SemanticId]) -> bool {
863 !dependencies.is_empty() && !matches!(expression.kind, SemanticExprKind::Reference(_))
864}
865
866fn match_source_requires_computed(expression: &SemanticExpr, dependencies: &[SemanticId]) -> bool {
867 !dependencies.is_empty()
868 && (matches!(expression.ty, noxid_types::Type::Optional(_))
869 || source_requires_computed(expression, dependencies))
870}
871
872fn build_template(nodes: &[DomNode], builder: &mut TemplateBuilder<'_>) -> Template {
873 let mut output = Template::default();
874 for node in nodes {
875 match node {
876 DomNode::StaticText(value) => output.html.push_str(&html_escape(value)),
877 DomNode::DynamicText {
878 id,
879 expression,
880 dependencies,
881 } => {
882 builder.marker += 1;
883 output
884 .html
885 .push_str(&format!("<!--noxid-text-{}-->", builder.marker));
886 output.operations.push(Operation::Text {
887 id: id.clone(),
888 marker: builder.marker,
889 expression: expression.clone(),
890 dependencies: dependencies.clone(),
891 });
892 }
893 DomNode::Element {
894 tag,
895 attributes,
896 attachments,
897 prefetch,
898 children,
899 } => {
900 output.html.push('<');
901 output.html.push_str(tag);
902 output
903 .html
904 .push_str(&format!(" data-noxid-scope=\"{}\"", builder.scope));
905 for attribute in attributes {
906 match attribute {
907 DomAttribute::Static { name, value } => output
908 .html
909 .push_str(&format!(" {name}=\"{}\"", html_escape(value))),
910 DomAttribute::Dynamic {
911 id,
912 name,
913 expression,
914 dependencies,
915 } => {
916 builder.marker += 1;
917 output
918 .html
919 .push_str(&format!(" data-noxid-bind-{}=\"\"", builder.marker));
920 output.operations.push(Operation::Attribute {
921 id: id.clone(),
922 marker: builder.marker,
923 name: name.clone(),
924 expression: expression.clone(),
925 dependencies: dependencies.clone(),
926 });
927 }
928 DomAttribute::Event {
929 id,
930 name,
931 action,
932 arguments,
933 } => {
934 builder.marker += 1;
935 output
936 .html
937 .push_str(&format!(" data-noxid-event-{}=\"\"", builder.marker));
938 output.operations.push(Operation::Event {
939 id: id.clone(),
940 marker: builder.marker,
941 event: name.clone(),
942 action: action.clone(),
943 arguments: arguments.clone(),
944 });
945 }
946 DomAttribute::TwoWayBinding { id, name, target } => {
947 builder.marker += 1;
948 output
949 .html
950 .push_str(&format!(" data-noxid-two-way-{}=\"\"", builder.marker));
951 output.operations.push(Operation::TwoWayBinding {
952 id: id.clone(),
953 marker: builder.marker,
954 name: name.clone(),
955 target: target.clone(),
956 });
957 }
958 }
959 }
960 for attachment in attachments {
961 builder.marker += 1;
962 output
963 .html
964 .push_str(&format!(" data-noxid-attach-{}=\"\"", builder.marker));
965 output.operations.push(Operation::Attachment {
966 attachment: attachment.clone(),
967 marker: builder.marker,
968 });
969 }
970 if let Some(trigger) = prefetch {
971 builder.marker += 1;
972 output
973 .html
974 .push_str(&format!(" data-noxid-prefetch-{}=\"\"", builder.marker));
975 output.operations.push(Operation::RoutePrefetch {
976 marker: builder.marker,
977 trigger: *trigger,
978 });
979 }
980 output.html.push('>');
981 let child = build_template(children, builder);
982 output.html.push_str(&child.html);
983 output.operations.extend(child.operations);
984 output.html.push_str("</");
985 output.html.push_str(tag);
986 output.html.push('>');
987 }
988 DomNode::Component {
989 id,
990 target,
991 props,
992 handlers,
993 prefetch,
994 children,
995 } => {
996 builder.marker += 1;
997 let component_marker = builder.marker;
998 let slot_children = if children.is_empty() {
999 None
1000 } else {
1001 builder.slot_children += 1;
1002 let block_name = format!(
1003 "{}SlotChildren{}Template",
1004 builder.component, builder.slot_children
1005 );
1006 let child = build_template(children, builder);
1007 builder.declarations.push((block_name.clone(), child.html));
1008 Some(SlotChildren {
1009 template_name: block_name,
1010 operations: child.operations,
1011 })
1012 };
1013 output
1014 .html
1015 .push_str(&format!("<!--noxid-component-{component_marker}-->"));
1016 output.operations.push(Operation::Component {
1017 id: id.clone(),
1018 marker: component_marker,
1019 target: target.clone(),
1020 props: props.clone(),
1021 handlers: handlers.clone(),
1022 prefetch: *prefetch,
1023 children: slot_children,
1024 });
1025 }
1026 DomNode::Slot { id } => {
1027 builder.marker += 1;
1028 output
1029 .html
1030 .push_str(&format!("<!--noxid-slot-{}-->", builder.marker));
1031 output.operations.push(Operation::Slot {
1032 id: id.clone(),
1033 marker: builder.marker,
1034 });
1035 }
1036 DomNode::Conditional {
1037 id,
1038 condition,
1039 dependencies,
1040 transition,
1041 children,
1042 } => {
1043 builder.marker += 1;
1044 let conditional_marker = builder.marker;
1045 builder.conditional += 1;
1046 let block_name = format!("{}If{}Template", builder.component, builder.conditional);
1047 let child = build_template(children, builder);
1048 builder.declarations.push((block_name.clone(), child.html));
1049 output
1050 .html
1051 .push_str(&format!("<!--noxid-if-{conditional_marker}-->"));
1052 output.operations.push(Operation::Conditional {
1053 id: id.clone(),
1054 marker: conditional_marker,
1055 condition: condition.clone(),
1056 dependencies: dependencies.clone(),
1057 transition: transition.clone(),
1058 template_name: block_name,
1059 operations: child.operations,
1060 });
1061 }
1062 DomNode::Match {
1063 id,
1064 expression,
1065 dependencies,
1066 cases,
1067 } => {
1068 builder.marker += 1;
1069 let match_marker = builder.marker;
1070 builder.match_block += 1;
1071 let match_ordinal = builder.match_block;
1072 let mut compiled_cases = Vec::new();
1073 for case in cases {
1074 let variant = symbol_name(&case.variant).to_string();
1075 let template_name =
1076 format!("{}Match{match_ordinal}{variant}Template", builder.component);
1077 let child = build_template(&case.children, builder);
1078 builder
1079 .declarations
1080 .push((template_name.clone(), child.html));
1081 compiled_cases.push(MatchOperationCase {
1082 variant,
1083 binding: case.binding.clone(),
1084 template_name,
1085 operations: child.operations,
1086 });
1087 }
1088 output
1089 .html
1090 .push_str(&format!("<!--noxid-match-{match_marker}-->"));
1091 output.operations.push(Operation::Match {
1092 id: id.clone(),
1093 marker: match_marker,
1094 expression: expression.clone(),
1095 dependencies: dependencies.clone(),
1096 cases: compiled_cases,
1097 });
1098 }
1099 DomNode::Stream {
1100 id,
1101 expression,
1102 dependencies,
1103 cases,
1104 } => {
1105 builder.marker += 1;
1106 let stream_marker = builder.marker;
1107 builder.stream_block += 1;
1108 let stream_ordinal = builder.stream_block;
1109 let mut compiled_cases = Vec::new();
1110 for case in cases {
1111 let variant = symbol_name(&case.variant).to_string();
1112 let template_name = format!(
1113 "{}Stream{stream_ordinal}{variant}Template",
1114 builder.component
1115 );
1116 let child = build_template(&case.children, builder);
1117 builder
1118 .declarations
1119 .push((template_name.clone(), child.html));
1120 compiled_cases.push(MatchOperationCase {
1121 variant,
1122 binding: case.binding.clone(),
1123 template_name,
1124 operations: child.operations,
1125 });
1126 }
1127 output
1128 .html
1129 .push_str(&format!("<!--noxid-stream-{stream_marker}-->"));
1130 output.operations.push(Operation::Stream {
1131 id: id.clone(),
1132 marker: stream_marker,
1133 expression: expression.clone(),
1134 dependencies: dependencies.clone(),
1135 cases: compiled_cases,
1136 });
1137 }
1138 DomNode::For {
1139 id,
1140 binding,
1141 collection,
1142 dependencies,
1143 key,
1144 children,
1145 } => {
1146 builder.marker += 1;
1147 let for_marker = builder.marker;
1148 builder.for_block += 1;
1149 let template_name =
1150 format!("{}For{}Template", builder.component, builder.for_block);
1151 let child = build_template(children, builder);
1152 builder
1153 .declarations
1154 .push((template_name.clone(), child.html));
1155 output
1156 .html
1157 .push_str(&format!("<!--noxid-for-{for_marker}-->"));
1158 output.operations.push(Operation::For {
1159 id: id.clone(),
1160 marker: for_marker,
1161 collection: collection.clone(),
1162 dependencies: dependencies.clone(),
1163 binding: binding.clone(),
1164 key: key.clone(),
1165 template_name,
1166 operations: child.operations,
1167 });
1168 }
1169 }
1170 }
1171 output
1172}
1173
1174fn emit_component(
1175 js: &mut String,
1176 program: &SemanticProgram,
1177 component: &ComponentDefinition,
1178 template_name: &str,
1179 operations: &[Operation],
1180) -> Result<(), String> {
1181 let hydration = supports_hydration(component);
1182 emit_hmr_action_factory(js, program, component)?;
1183 let guarded = !component.middleware.is_empty() || !component.capabilities.is_empty();
1184 let query_client = if component.resources.is_empty() {
1185 ""
1186 } else {
1187 "\n if (!resourceOptions.queryClient) resourceOptions = { ...resourceOptions, queryClient: __noxidQueryClient };"
1188 };
1189 let resource_options_binding = if component.resources.is_empty() {
1190 "const"
1191 } else {
1192 "let"
1193 };
1194 let hydrating_parameter = if hydration {
1195 ", $noxHydrating = false"
1196 } else {
1197 ""
1198 };
1199 js.push_str(&format!("export function mount{}($noxRoot, $noxProps = {{}}, $noxEventHandlers = {{}}, $noxParentOwner = null, $noxRuntimeOptions = null{hydrating_parameter}) {{\n const $noxOwner = createOwner($noxParentOwner, {{ semanticId: \"{}\", label: \"{}\" }});\n {} $noxResourceOptions = $noxRuntimeOptions ?? $noxParentOwner?.resourceOptions ?? {{}};{}\n $noxOwner.resourceOptions = $noxResourceOptions;\n const $noxEmit = componentEmitter($noxEventHandlers, \"{}\");\n", component.name, js_escape(component.id.as_str()), js_escape(&component.name), resource_options_binding, query_client.replace("resourceOptions", "$noxResourceOptions"), js_escape(&component.name)));
1200 if component
1201 .actions
1202 .iter()
1203 .any(|action| action.execution.is_remote())
1204 {
1205 js.push_str(" const $noxExecuteBoundary = $noxResourceOptions.executeBoundary;\n");
1206 }
1207 if guarded {
1208 let middleware = component
1209 .middleware
1210 .iter()
1211 .map(|usage| format!("\"{}\"", js_escape(&usage.name)))
1212 .collect::<Vec<_>>()
1213 .join(", ");
1214 let capabilities = component
1215 .capabilities
1216 .iter()
1217 .map(|capability| format!("\"{}\"", js_escape(capability.id.as_str())))
1218 .collect::<Vec<_>>()
1219 .join(", ");
1220 js.push_str(&format!(
1221 " try {{ authorizeComponent($noxResourceOptions, \"{}\", [{}], [{}]); }} catch ($noxError) {{ disposeOwner($noxOwner); throw $noxError; }}\n",
1222 js_escape(component.id.as_str()),
1223 capabilities,
1224 middleware,
1225 ));
1226 }
1227 for prop in &component.props {
1228 js.push_str(&format!(
1229 " const {} = propSource($noxProps, \"{}\", \"{}\");\n",
1230 prop.name,
1231 js_escape(&prop.name),
1232 js_escape(&component.name)
1233 ));
1234 }
1235 for context_use in &component.context_uses {
1236 js.push_str(&format!(
1237 " const $noxContext_{} = useContext($noxOwner, \"{}\", \"{}\");\n",
1238 context_use.name,
1239 js_escape(context_use.context.as_str()),
1240 js_escape(&component.name)
1241 ));
1242 for field in &context_use.fields {
1243 js.push_str(&format!(
1244 " const {} = $noxContext_{}[\"{}\"];\n",
1245 field.name,
1246 context_use.name,
1247 js_escape(&field.name)
1248 ));
1249 }
1250 }
1251 for state in &component.states {
1252 let constructor = if matches!(state.ty, noxid_types::Type::Array(_)) {
1253 "collectionSignal"
1254 } else {
1255 "signal"
1256 };
1257 let validator = state_validator_id(program, component, &state.ty)
1258 .map(|id| {
1259 format!(
1260 ", validatorId: \"{}\", validator: __noxidTypeValidators[\"{}\"]",
1261 js_escape(&id),
1262 js_escape(&id)
1263 )
1264 })
1265 .unwrap_or_default();
1266 js.push_str(&format!(
1267 " const $noxInitial_{} = {};\n const {} = {constructor}(globalThis.__NOXID_HMR__ ? globalThis.__NOXID_HMR__.initialState(\"{}\", $noxInitial_{}) : $noxInitial_{}, {{ semanticId: \"{}\", type: \"{}\", owner: $noxOwner{validator} }});\n",
1268 state.name,
1269 emit_expr(&state.initializer)?,
1270 state.name,
1271 js_escape(state.id.as_str()),
1272 state.name,
1273 state.name,
1274 js_escape(state.id.as_str()),
1275 js_escape(&state.ty.to_string())
1276 ));
1277 }
1278 for computed in &component.computed {
1279 js.push_str(&format!(
1280 " const {} = computed(() => {}, $noxOwner, {}, {{ semanticId: \"{}\" }});\n",
1281 computed.name,
1282 emit_expr(&computed.expression)?,
1283 emit_sources(&computed.dependencies),
1284 js_escape(computed.id.as_str())
1285 ));
1286 }
1287 for resource in &component.resources {
1288 let arguments = resource
1289 .arguments
1290 .iter()
1291 .map(|argument| {
1292 Ok(format!(
1293 "[\"{}\"]: {}",
1294 js_escape(&argument.name),
1295 emit_expr(&argument.value)?
1296 ))
1297 })
1298 .collect::<Result<Vec<_>, String>>()?
1299 .join(", ");
1300 let mut dependencies = resource
1301 .arguments
1302 .iter()
1303 .flat_map(|argument| argument.dependencies.clone())
1304 .collect::<Vec<_>>();
1305 dependencies.sort();
1306 dependencies.dedup();
1307 js.push_str(&format!(
1308 " const $noxResourceHandle_{} = acquireResource({}, () => ({{ {} }}), $noxOwner, {}, $noxResourceOptions, \"{}\");\n const {} = $noxResourceHandle_{}.state;\n",
1309 resource.name,
1310 resource.resource_name,
1311 arguments,
1312 emit_sources(&dependencies),
1313 js_escape(resource.id.as_str()),
1314 resource.name,
1315 resource.name,
1316 ));
1317 if !resource.refresh.is_empty() {
1318 js.push_str(&format!(
1319 " attachResourceRefreshTriggers($noxResourceHandle_{}, Object.freeze([{}]), $noxOwner, $noxResourceOptions);\n",
1320 resource.name,
1321 emit_resource_refresh_triggers(&resource.refresh),
1322 ));
1323 }
1324 }
1325 for stream in &component.streams {
1326 let arguments = stream
1327 .arguments
1328 .iter()
1329 .map(|argument| {
1330 Ok(format!(
1331 "[\"{}\"]: {}",
1332 js_escape(&argument.name),
1333 emit_expr(&argument.value)?
1334 ))
1335 })
1336 .collect::<Result<Vec<_>, String>>()?
1337 .join(", ");
1338 let mut dependencies = stream
1339 .arguments
1340 .iter()
1341 .flat_map(|argument| argument.dependencies.clone())
1342 .collect::<Vec<_>>();
1343 dependencies.sort();
1344 dependencies.dedup();
1345 if let Some(presence) = component
1346 .presence
1347 .as_ref()
1348 .filter(|presence| presence.stream == stream.stream)
1349 {
1350 js.push_str(&format!(
1351 " const $noxStreamHandle_presence = acquirePresence({}, Object.freeze({{ id: \"{}\", stream: \"{}\", recordType: \"{}\", memberType: \"{}\", snapshotType: \"{}\", ttlMilliseconds: {}, heartbeatMilliseconds: {}, validateRecord: __noxidTypeValidators[\"{}\"] }}), () => ({{ {} }}), $noxOwner, {}, $noxResourceOptions, \"{}\");\n const presence = $noxStreamHandle_presence.events;\n",
1352 stream.stream_name,
1353 js_escape(presence.id.as_str()),
1354 js_escape(presence.stream.as_str()),
1355 js_escape(presence.record_type.as_str()),
1356 js_escape(presence.member_type.as_str()),
1357 js_escape(presence.snapshot_type.as_str()),
1358 presence.ttl_milliseconds,
1359 presence.heartbeat_milliseconds,
1360 js_escape(presence.record_type.as_str()),
1361 arguments,
1362 emit_sources(&dependencies),
1363 js_escape(stream.id.as_str()),
1364 ));
1365 continue;
1366 }
1367 js.push_str(&format!(
1368 " const $noxStreamHandle_{} = acquireStream({}, () => ({{ {} }}), $noxOwner, {}, $noxResourceOptions, \"{}\");\n const {} = $noxStreamHandle_{}.events;\n",
1369 stream.name,
1370 stream.stream_name,
1371 arguments,
1372 emit_sources(&dependencies),
1373 js_escape(stream.id.as_str()),
1374 stream.name,
1375 stream.name,
1376 ));
1377 }
1378 for agent in &component.agents {
1379 js.push_str(&format!(
1380 " const $noxAgentHandle_{} = acquireAgent({}, () => ({}), $noxOwner, {}, $noxResourceOptions, \"{}\");\n const {} = $noxAgentHandle_{}.events;\n",
1381 agent.name,
1382 agent.agent_name,
1383 emit_expr(&agent.input)?,
1384 emit_sources(&agent.dependencies),
1385 js_escape(agent.id.as_str()),
1386 agent.name,
1387 agent.name,
1388 ));
1389 }
1390 for provider in &component.context_providers {
1391 let fields = provider
1392 .fields
1393 .iter()
1394 .map(|field| {
1395 Ok(format!(
1396 "\"{}\": {}",
1397 js_escape(&field.name),
1398 emit_source(&field.value, &field.dependencies, "$noxOwner")?
1399 ))
1400 })
1401 .collect::<Result<Vec<_>, String>>()?
1402 .join(", ");
1403 js.push_str(&format!(
1404 " provideContext($noxOwner, \"{}\", {{ {fields} }});\n",
1405 js_escape(provider.context.as_str())
1406 ));
1407 }
1408 let scope_names = hmr_scope_names(component);
1409 let resource_options_scope = if component
1410 .actions
1411 .iter()
1412 .any(|action| !action.invalidation.resources.is_empty())
1413 {
1414 ", $noxResourceOptions"
1415 } else {
1416 ""
1417 };
1418 js.push_str(&format!(
1419 " const $noxActionScope = {{ $noxOwner, $noxEmit{resource_options_scope}{}{} }};\n let $noxActions = __noxidCreate{}Actions($noxActionScope);\n",
1420 scope_names
1421 .iter()
1422 .map(|name| format!(", {name}"))
1423 .collect::<String>(),
1424 if component.actions.iter().any(|action| action.execution.is_remote()) {
1425 ", $noxExecuteBoundary"
1426 } else {
1427 ""
1428 },
1429 component.name,
1430 ));
1431 for action in &component.actions {
1432 js.push_str(&format!(
1433 " function {}(...$noxInputs) {{ return $noxActions.{}(...$noxInputs); }}\n",
1434 action.name, action.name,
1435 ));
1436 }
1437 for reactive in &component.effects {
1438 match &reactive.kind {
1439 ReactiveEffectKind::Effect => {
1440 js.push_str(" effect(() => batch(() => {\n");
1441 emit_statements(js, &reactive.statements, 2)?;
1442 js.push_str(&format!(
1443 " }}), $noxOwner, {}, {{ semanticId: \"{}\" }});\n",
1444 emit_sources(&reactive.dependencies),
1445 js_escape(reactive.id.as_str())
1446 ));
1447 }
1448 ReactiveEffectKind::Watch {
1449 expression,
1450 current,
1451 previous,
1452 } => {
1453 js.push_str(&format!(
1454 " watch(() => {}, ($noxWatchCurrentInput, $noxWatchPreviousInput) => {{\n",
1455 emit_expr(expression)?,
1456 ));
1457 for (parameter, input) in [
1458 (current, "$noxWatchCurrentInput"),
1459 (previous, "$noxWatchPreviousInput"),
1460 ] {
1461 js.push_str(&format!(
1462 " const {} = toSource({input});\n",
1463 parameter.name
1464 ));
1465 }
1466 js.push_str(" return batch(() => {\n");
1467 emit_statements(js, &reactive.statements, 3)?;
1468 js.push_str(&format!(
1469 " }});\n }}, $noxOwner, {}, {{ semanticId: \"{}\" }});\n",
1470 emit_sources(&reactive.dependencies),
1471 js_escape(reactive.id.as_str())
1472 ));
1473 }
1474 }
1475 }
1476 if hydration {
1477 js.push_str(" if ($noxHydrating) {\n");
1478 emit_hydration_operations(js, operations, "$noxRoot", "$noxOwner", 2)?;
1479 js.push_str(" } else {\n");
1480 js.push_str(&format!(
1481 " const $noxFragment = {template_name}.content.cloneNode(true);\n"
1482 ));
1483 emit_operations(js, operations, "$noxFragment", "$noxOwner", 2)?;
1484 js.push_str(" $noxRoot.replaceChildren($noxFragment);\n }\n");
1485 } else {
1486 js.push_str(&format!(
1487 " const $noxFragment = {template_name}.content.cloneNode(true);\n"
1488 ));
1489 emit_operations(js, operations, "$noxFragment", "$noxOwner", 1)?;
1490 js.push_str(" $noxRoot.replaceChildren($noxFragment);\n");
1491 }
1492 for behavior in &component.behaviors {
1493 let legal = behavior
1494 .legal_elements
1495 .iter()
1496 .map(|value| format!("\"{}\"", js_escape(value)))
1497 .collect::<Vec<_>>()
1498 .join(", ");
1499 let keyboard = behavior
1500 .keyboard
1501 .iter()
1502 .map(|value| format!("\"{}\"", js_escape(value)))
1503 .collect::<Vec<_>>()
1504 .join(", ");
1505 let effects = behavior
1506 .effects
1507 .iter()
1508 .map(|value| format!("\"{}\"", js_escape(value)))
1509 .collect::<Vec<_>>()
1510 .join(", ");
1511 let events = behavior.events.iter().map(|event| {
1512 if event.event == "keydown" && !behavior.keyboard.is_empty() {
1513 format!("\"keydown\": ($noxEvent) => {{ if ([{keyboard}].includes($noxEvent.key)) return $noxActions.{}(); }}", event.action_name)
1514 } else {
1515 format!("\"{}\": () => $noxActions.{}()", js_escape(&event.event), event.action_name)
1516 }
1517 }).collect::<Vec<_>>().join(", ");
1518 js.push_str(&format!(
1519 " for (const $noxElement of $noxRoot.querySelectorAll('[behavior=\"{}\"]')) applyBehavior($noxElement, $noxOwner, Object.freeze({{ legalElements: Object.freeze([{legal}]), keyboard: Object.freeze([{keyboard}]), effects: Object.freeze([{effects}]), events: Object.freeze({{ {events} }}), ssr: \"{}\" }}), Object.freeze({{}}), Object.freeze({{ semanticId: \"{}\" }}));\n",
1520 js_escape(&behavior.name),
1521 js_escape(&behavior.ssr),
1522 js_escape(behavior.id.as_str()),
1523 ));
1524 }
1525 for region in &component.regions {
1526 let role = region
1527 .semantic_role
1528 .as_ref()
1529 .map(|value| format!("\"{}\"", js_escape(value)))
1530 .unwrap_or_else(|| "null".into());
1531 js.push_str(&format!(
1532 " validateComponentRegion([ ...$noxRoot.querySelectorAll('[region=\"{}\"]')], Object.freeze({{ cardinality: \"{}\", contentType: \"{}\", semanticRole: {role} }}), $noxOwner, Object.freeze({{ semanticId: \"{}\" }}));\n",
1533 js_escape(®ion.name),
1534 js_escape(®ion.cardinality),
1535 js_escape(®ion.content_type.to_string()),
1536 js_escape(region.id.as_str()),
1537 ));
1538 }
1539 if let Some(lifecycle) = &component.lifecycle {
1540 let mount = lifecycle
1541 .mount
1542 .as_ref()
1543 .map(|id| symbol_name(id).to_string())
1544 .unwrap_or_else(|| "null".into());
1545 let cleanup = lifecycle
1546 .cleanup
1547 .as_ref()
1548 .map(|id| symbol_name(id).to_string())
1549 .unwrap_or_else(|| "null".into());
1550 js.push_str(&format!(
1551 " try {{ registerLifecycle($noxOwner, \"{}\", {mount}, {cleanup}); }} catch ($noxError) {{ disposeOwner($noxOwner); $noxRoot.replaceChildren(); throw $noxError; }}\n",
1552 js_escape(lifecycle.id.as_str())
1553 ));
1554 }
1555 let state_handles = component
1556 .states
1557 .iter()
1558 .map(|state| format!("\"{}\": {}", js_escape(state.id.as_str()), state.name))
1559 .collect::<Vec<_>>()
1560 .join(", ");
1561 js.push_str(&format!(
1562 " globalThis.__NOXID_HMR__?.registerInstance({{ component: \"{}\", owner: $noxOwner, state: {{ {state_handles} }}, patchActions($noxNextModule) {{ if (typeof $noxNextModule.__noxidCreate{}Actions === \"function\") $noxActions = $noxNextModule.__noxidCreate{}Actions($noxActionScope); }} }});\n",
1563 js_escape(&component.name),
1564 component.name,
1565 component.name,
1566 ));
1567 if component.resources.is_empty() && component.streams.is_empty() && component.agents.is_empty()
1568 {
1569 js.push_str(" return { owner: $noxOwner, dispose() { disposeOwner($noxOwner); $noxRoot.replaceChildren(); } };\n}\n\n");
1570 } else {
1571 let resource_handles = component
1572 .resources
1573 .iter()
1574 .map(|resource| format!("{}: $noxResourceHandle_{}", resource.name, resource.name))
1575 .collect::<Vec<_>>()
1576 .join(", ");
1577 let stream_handles = component
1578 .streams
1579 .iter()
1580 .map(|stream| format!("{}: $noxStreamHandle_{}", stream.name, stream.name))
1581 .collect::<Vec<_>>()
1582 .join(", ");
1583 let agent_handles = component
1584 .agents
1585 .iter()
1586 .map(|agent| format!("{}: $noxAgentHandle_{}", agent.name, agent.name))
1587 .collect::<Vec<_>>()
1588 .join(", ");
1589 js.push_str(&format!(" return {{ owner: $noxOwner, resources: {{ {resource_handles} }}, streams: {{ {stream_handles} }}, agents: {{ {agent_handles} }}, dispose() {{ disposeOwner($noxOwner); $noxRoot.replaceChildren(); }} }};\n}}\n\n"));
1590 }
1591 if !component.resources.is_empty() {
1592 emit_component_prefetch(js, component)?;
1593 }
1594 if hydration {
1595 js.push_str(&format!(
1596 "export function hydrate{}($noxRoot, $noxProps = {{}}, $noxEventHandlers = {{}}, $noxParentOwner = null, $noxRuntimeOptions = null) {{\n return mount{}($noxRoot, $noxProps, $noxEventHandlers, $noxParentOwner, $noxRuntimeOptions, true);\n}}\n\n",
1597 component.name,
1598 component.name,
1599 ));
1600 }
1601 Ok(())
1602}
1603
1604fn emit_component_prefetch(js: &mut String, component: &ComponentDefinition) -> Result<(), String> {
1605 let prop_ids = component
1606 .props
1607 .iter()
1608 .map(|prop| prop.id.clone())
1609 .collect::<BTreeSet<_>>();
1610 let available = component.resources.iter().all(|resource| {
1611 resource
1612 .arguments
1613 .iter()
1614 .flat_map(|argument| &argument.dependencies)
1615 .all(|dependency| prop_ids.contains(dependency))
1616 });
1617 js.push_str(&format!(
1618 "export async function __noxidPrefetch{}($noxProps = {{}}, $noxRuntimeOptions = null) {{\n",
1619 component.name
1620 ));
1621 if !available {
1622 js.push_str(&format!(
1623 " const $noxError = new Error(\"Component {} cannot prefetch because a resource argument depends on component state\");\n $noxError.code = \"PREFETCH_RESOURCE_ARGUMENT_UNAVAILABLE\";\n throw $noxError;\n}}\n\n",
1624 js_escape(&component.name)
1625 ));
1626 return Ok(());
1627 }
1628 js.push_str(&format!(
1629 " const $noxOwner = createOwner(null, {{ semanticId: \"{}\", label: \"component-prefetch\" }});\n let $noxResourceOptions = $noxRuntimeOptions ?? {{}};\n if (!$noxResourceOptions.queryClient) $noxResourceOptions = {{ ...$noxResourceOptions, queryClient: __noxidQueryClient }};\n",
1630 js_escape(component.id.as_str())
1631 ));
1632 for prop in &component.props {
1633 js.push_str(&format!(
1634 " const {} = propSource($noxProps, \"{}\", \"{}\");\n",
1635 prop.name,
1636 js_escape(&prop.name),
1637 js_escape(&component.name)
1638 ));
1639 }
1640 let requests = component
1641 .resources
1642 .iter()
1643 .map(|resource| {
1644 let arguments = resource
1645 .arguments
1646 .iter()
1647 .map(|argument| {
1648 Ok(format!(
1649 "[\"{}\"]: {}",
1650 js_escape(&argument.name),
1651 emit_expr(&argument.value)?
1652 ))
1653 })
1654 .collect::<Result<Vec<_>, String>>()?
1655 .join(", ");
1656 Ok(format!(
1657 "{}.prefetch({{ {arguments} }}, {{ ...$noxResourceOptions, owner: $noxOwner }})",
1658 resource.resource_name
1659 ))
1660 })
1661 .collect::<Result<Vec<_>, String>>()?
1662 .join(", ");
1663 js.push_str(&format!(
1664 " try {{\n await Promise.all([{requests}]);\n }} finally {{\n disposeOwner($noxOwner);\n }}\n}}\n\n"
1665 ));
1666 Ok(())
1667}
1668
1669fn emit_hmr_action_factory(
1670 js: &mut String,
1671 program: &SemanticProgram,
1672 component: &ComponentDefinition,
1673) -> Result<(), String> {
1674 let scope_names = hmr_scope_names(component);
1675 let boundary_scope = if component
1676 .actions
1677 .iter()
1678 .any(|action| action.execution.is_remote())
1679 {
1680 "\n const $noxExecuteBoundary = $noxScope.$noxExecuteBoundary ?? $noxScope.executeBoundary;"
1681 } else {
1682 ""
1683 };
1684 let resource_options_scope = if component
1685 .actions
1686 .iter()
1687 .any(|action| !action.invalidation.resources.is_empty())
1688 {
1689 "\n const $noxResourceOptions = $noxScope.$noxResourceOptions ?? $noxScope.resourceOptions ?? {};"
1690 } else {
1691 ""
1692 };
1693 js.push_str(&format!(
1694 "export function __noxidCreate{}Actions($noxScope) {{\n const $noxOwner = $noxScope.$noxOwner ?? $noxScope.owner;\n const $noxEmit = $noxScope.$noxEmit ?? $noxScope.emit;{resource_options_scope}\n const {{ {} }} = $noxScope;{boundary_scope}\n",
1695 component.name,
1696 scope_names.join(", "),
1697 ));
1698 for action in &component.actions {
1699 let inputs = action
1700 .parameters
1701 .iter()
1702 .map(|parameter| format!("$noxInput_{}", parameter.name))
1703 .collect::<Vec<_>>()
1704 .join(", ");
1705 let remote_await = action
1706 .statements
1707 .iter()
1708 .position(|statement| matches!(statement, SemanticStatement::RemoteAwait { .. }));
1709 let async_keyword = if remote_await.is_some() { "async " } else { "" };
1710 js.push_str(&format!(
1711 " {async_keyword}function {}({inputs}) {{\n",
1712 action.name
1713 ));
1714 if action.execution.is_remote() {
1715 let arguments = action
1716 .parameters
1717 .iter()
1718 .map(|parameter| (parameter, format!("$noxInput_{}", parameter.name)))
1719 .collect::<Vec<_>>();
1720 let descriptor = emit_remote_action_descriptor(component, action, &arguments);
1721 let invalidation = emit_action_invalidation(program, action);
1722 js.push_str(&format!(
1723 " return Promise.resolve(runAction(\"{}\", $noxOwner, () => {{\n if (typeof $noxExecuteBoundary !== \"function\") {{ const $noxError = new Error(\"No host executor is configured for {} action {}\"); $noxError.code = \"EXECUTION_BOUNDARY_UNAVAILABLE\"; $noxError.semanticId = \"{}\"; $noxError.execution = \"{}\"; throw $noxError; }}\n return $noxExecuteBoundary({descriptor});\n }})).then(($noxValue) => {{{invalidation} return $noxValue; }});\n }}\n",
1724 js_escape(action.id.as_str()),
1725 action.execution.as_str(),
1726 js_escape(&action.name),
1727 js_escape(action.id.as_str()),
1728 action.execution.as_str(),
1729 ));
1730 } else {
1731 for parameter in &action.parameters {
1732 js.push_str(&format!(
1733 " const {} = toSource($noxInput_{});\n",
1734 parameter.name, parameter.name
1735 ));
1736 }
1737 if let Some(remote_await) = remote_await {
1738 emit_async_action(js, program, component, action, remote_await)?;
1739 } else {
1740 js.push_str(&format!(
1741 " return runAction(\"{}\", $noxOwner, () => {{\n",
1742 js_escape(action.id.as_str())
1743 ));
1744 emit_statements(js, &action.statements, 4)?;
1745 js.push_str(" });\n }\n");
1746 }
1747 }
1748 }
1749 js.push_str(&format!(
1750 " return {{ {} }};\n}}\n\n",
1751 component
1752 .actions
1753 .iter()
1754 .map(|action| action.name.as_str())
1755 .collect::<Vec<_>>()
1756 .join(", ")
1757 ));
1758 Ok(())
1759}
1760
1761fn emit_async_action(
1762 js: &mut String,
1763 program: &SemanticProgram,
1764 component: &ComponentDefinition,
1765 action: &Action,
1766 remote_await_index: usize,
1767) -> Result<(), String> {
1768 let SemanticStatement::RemoteAwait {
1769 action: remote_action_id,
1770 arguments,
1771 ok_arm,
1772 err_arm,
1773 ..
1774 } = &action.statements[remote_await_index]
1775 else {
1776 unreachable!("remote await index must identify a RemoteAwait statement")
1777 };
1778 let remote_action = component
1779 .actions
1780 .iter()
1781 .find(|candidate| candidate.id == *remote_action_id)
1782 .unwrap_or_else(|| {
1783 panic!(
1784 "remote await references missing sibling action `{}`",
1785 remote_action_id
1786 )
1787 });
1788 let descriptor_arguments = arguments
1789 .iter()
1790 .map(|argument| {
1791 let parameter = remote_action
1792 .parameters
1793 .iter()
1794 .find(|parameter| parameter.id == argument.parameter)
1795 .unwrap_or_else(|| {
1796 panic!(
1797 "remote await argument `{}` references missing parameter `{}`",
1798 argument.name, argument.parameter
1799 )
1800 });
1801 Ok((parameter, emit_expr(&argument.value)?))
1802 })
1803 .collect::<Result<Vec<_>, String>>()?;
1804 let descriptor = emit_remote_action_descriptor(component, remote_action, &descriptor_arguments);
1805 let remote_invalidation = emit_action_invalidation(program, remote_action);
1806 let action_id = js_escape(action.id.as_str());
1807 let before_await = &action.statements[..remote_await_index];
1808
1809 for statement in before_await {
1814 if let SemanticStatement::Local { name, .. } = statement {
1815 js.push_str(&format!(" let {name};\n"));
1816 }
1817 }
1818
1819 js.push_str(&format!(
1820 " runAction(\"{action_id}\", $noxOwner, () => {{\n"
1821 ));
1822 emit_pre_await_statements(js, before_await, 4)?;
1823 js.push_str(" });\n");
1824 js.push_str(" const $noxRemoteOutcome = await (async () => {\n");
1825 js.push_str(&format!(
1826 " try {{\n if (typeof $noxExecuteBoundary !== \"function\") {{ const $noxError = new Error(\"No host executor is configured for {} action {}\"); $noxError.code = \"EXECUTION_BOUNDARY_UNAVAILABLE\"; $noxError.semanticId = \"{}\"; $noxError.execution = \"{}\"; throw $noxError; }}\n const $noxValue = await $noxExecuteBoundary({descriptor});\n {remote_invalidation}return Object.freeze({{ tag: \"Ok\", value: $noxValue }});\n }} catch ($noxThrown) {{\n const $noxRemoteError = Object.freeze({{ code: typeof $noxThrown?.code === \"string\" ? $noxThrown.code : \"REMOTE_ACTION_FAILED\", message: typeof $noxThrown?.message === \"string\" ? $noxThrown.message : String($noxThrown) }});\n return Object.freeze({{ tag: \"Err\", value: $noxRemoteError }});\n }}\n",
1827 remote_action.execution.as_str(),
1828 js_escape(&remote_action.name),
1829 js_escape(remote_action.id.as_str()),
1830 remote_action.execution.as_str(),
1831 ));
1832 js.push_str(" })();\n");
1833 js.push_str(&format!(
1834 " return runAction(\"{action_id}\", $noxOwner, () => {{\n"
1835 ));
1836 emit_remote_await_arm(js, ok_arm, 4)?;
1837 emit_remote_await_arm(js, err_arm, 4)?;
1838 emit_statements(js, &action.statements[remote_await_index + 1..], 4)?;
1839 js.push_str(" });\n }\n");
1840 Ok(())
1841}
1842
1843fn emit_pre_await_statements(
1844 js: &mut String,
1845 statements: &[SemanticStatement],
1846 depth: usize,
1847) -> Result<(), String> {
1848 let indent = " ".repeat(depth);
1849 for statement in statements {
1850 if let SemanticStatement::Local { name, value, .. } = statement {
1851 js.push_str(&format!(
1852 "{indent}{name} = toSource({});\n",
1853 emit_expr(value)?
1854 ));
1855 } else {
1856 emit_statements(js, std::slice::from_ref(statement), depth)?;
1857 }
1858 }
1859 Ok(())
1860}
1861
1862fn emit_remote_await_arm(
1863 js: &mut String,
1864 arm: &RemoteAwaitArm,
1865 depth: usize,
1866) -> Result<(), String> {
1867 let indent = " ".repeat(depth);
1868 let branch = if arm.name == "Ok" { "if" } else { "else if" };
1869 js.push_str(&format!(
1870 "{indent}{branch} ($noxRemoteOutcome.tag === \"{}\") {{\n",
1871 js_escape(&arm.name)
1872 ));
1873 if let Some(binding) = &arm.binding {
1874 js.push_str(&format!(
1875 "{indent} const {} = toSource($noxRemoteOutcome.value);\n",
1876 binding.name
1877 ));
1878 }
1879 emit_statements(js, &arm.statements, depth + 1)?;
1880 js.push_str(&format!("{indent}}}\n"));
1881 Ok(())
1882}
1883
1884fn emit_remote_action_descriptor(
1885 component: &ComponentDefinition,
1886 action: &Action,
1887 arguments: &[(&ActionParameter, String)],
1888) -> String {
1889 let arguments = arguments
1890 .iter()
1891 .map(|(parameter, value)| {
1892 let type_id = parameter
1893 .type_id
1894 .as_ref()
1895 .map(|id| format!("\"{}\"", js_escape(id.as_str())))
1896 .unwrap_or_else(|| "null".into());
1897 format!(
1898 "{{ name: \"{}\", type: \"{}\", typeId: {type_id}, value: {value} }}",
1899 js_escape(¶meter.name),
1900 parameter.ty,
1901 )
1902 })
1903 .collect::<Vec<_>>()
1904 .join(", ");
1905 let result_type_id = action
1906 .result
1907 .type_id
1908 .as_ref()
1909 .map(|id| format!("\"{}\"", js_escape(id.as_str())))
1910 .unwrap_or_else(|| "null".into());
1911 let capabilities = action
1912 .capabilities
1913 .iter()
1914 .map(|capability| format!("\"{}\"", js_escape(&capability.name)))
1915 .collect::<Vec<_>>()
1916 .join(", ");
1917 format!(
1918 "Object.freeze({{ id: \"{}\", component: \"{}\", action: \"{}\", execution: \"{}\", arguments: Object.freeze([{arguments}]), result: Object.freeze({{ id: \"{}\", type: \"{}\", typeId: {result_type_id} }}), capabilities: Object.freeze([{capabilities}]) }})",
1919 js_escape(action.id.as_str()),
1920 js_escape(&component.name),
1921 js_escape(&action.name),
1922 action.execution.as_str(),
1923 js_escape(action.result.id.as_str()),
1924 action.result.ty,
1925 )
1926}
1927
1928fn emit_action_invalidation(program: &SemanticProgram, action: &Action) -> String {
1929 if action.invalidation.resources.is_empty() {
1930 return String::new();
1931 }
1932 let definitions = action
1933 .invalidation
1934 .resources
1935 .iter()
1936 .map(|resource| resource_definition_name(program, resource))
1937 .collect::<Vec<_>>()
1938 .join(", ");
1939 format!(
1940 " invalidateCompiledResources($noxResourceOptions.queryClient ?? __noxidQueryClient, [{definitions}], $noxResourceOptions, \"{}\");",
1941 js_escape(action.id.as_str())
1942 )
1943}
1944
1945fn hmr_scope_names(component: &ComponentDefinition) -> Vec<String> {
1946 let mut names = std::collections::BTreeSet::new();
1947 names.extend(component.props.iter().map(|value| value.name.clone()));
1948 names.extend(
1949 component
1950 .context_uses
1951 .iter()
1952 .flat_map(|context| context.fields.iter().map(|field| field.name.clone())),
1953 );
1954 names.extend(component.states.iter().map(|value| value.name.clone()));
1955 names.extend(component.computed.iter().map(|value| value.name.clone()));
1956 names.extend(component.resources.iter().map(|value| value.name.clone()));
1957 names.extend(component.streams.iter().map(|value| value.name.clone()));
1958 names.extend(component.agents.iter().map(|value| value.name.clone()));
1959 names.into_iter().collect()
1960}
1961
1962fn emit_statements(
1963 js: &mut String,
1964 statements: &[SemanticStatement],
1965 depth: usize,
1966) -> Result<(), String> {
1967 let indent = " ".repeat(depth);
1968 for statement in statements {
1969 match statement {
1970 SemanticStatement::Return { value, .. } => {
1971 js.push_str(&format!("{indent}return {};\n", emit_expr(value)?,))
1972 }
1973 SemanticStatement::Assignment { target, value, .. } => js.push_str(&format!(
1974 "{indent}{}.set({});\n",
1975 symbol_name(target),
1976 emit_expr(value)?
1977 )),
1978 SemanticStatement::Transition {
1979 target,
1980 value,
1981 machine,
1982 event,
1983 allowed,
1984 ..
1985 } => {
1986 let allowed = allowed
1987 .iter()
1988 .map(|(from, to)| format!("[\"{}\", \"{}\"]", js_escape(from), js_escape(to)))
1989 .collect::<Vec<_>>()
1990 .join(", ");
1991 js.push_str(&format!(
1992 "{indent}transitionMachine({}, {}, \"{}\", [{}], \"{}\");\n",
1993 symbol_name(target),
1994 emit_expr(value)?,
1995 js_escape(event),
1996 allowed,
1997 js_escape(symbol_name(machine))
1998 ));
1999 }
2000 SemanticStatement::CollectionMutation {
2001 target,
2002 operation,
2003 arguments,
2004 ..
2005 } => js.push_str(&format!(
2006 "{indent}{}.{}({});\n",
2007 symbol_name(target),
2008 operation.as_str(),
2009 arguments
2010 .iter()
2011 .map(emit_expr)
2012 .collect::<Result<Vec<_>, String>>()?
2013 .join(", ")
2014 )),
2015 SemanticStatement::Emit { name, payload, .. } => js.push_str(&format!(
2016 "{indent}$noxEmit(\"{}\", {});\n",
2017 js_escape(name),
2018 emit_expr(payload)?
2019 )),
2020 SemanticStatement::FieldAssignment {
2021 target,
2022 path,
2023 value,
2024 ..
2025 } => {
2026 let base = format!("{}.get()", symbol_name(target));
2027 js.push_str(&format!(
2028 "{indent}{}.set({});\n",
2029 symbol_name(target),
2030 field_assignment_value(&base, path, &emit_expr(value)?)
2031 ));
2032 }
2033 SemanticStatement::Local { name, value, .. } => js.push_str(&format!(
2036 "{indent}let {} = toSource({});\n",
2037 name,
2038 emit_expr(value)?
2039 )),
2040 SemanticStatement::LocalAssignment { name, value, .. } => js.push_str(&format!(
2041 "{indent}{} = toSource({});\n",
2042 name,
2043 emit_expr(value)?
2044 )),
2045 SemanticStatement::RemoteAwait { .. } => {
2046 panic!("RemoteAwait must be lowered by the async client-action emitter")
2047 }
2048 SemanticStatement::PrincipalMatch { .. } => {
2054 return Err(noxid_ir::emitter_rejection(
2055 "PRINCIPAL_CONSTRUCTION_RESERVED",
2056 "`#match context.principal` has no client lowering; a principal exists only inside the generated server boundary, so keep the branch in a compiler-owned endpoint, task, or queue handler",
2057 ));
2058 }
2059 SemanticStatement::If {
2060 condition,
2061 then_statements,
2062 else_statements,
2063 ..
2064 } => {
2065 js.push_str(&format!("{indent}if ({}) {{\n", emit_expr(condition)?));
2066 emit_statements(js, then_statements, depth + 1)?;
2067 if else_statements.is_empty() {
2068 js.push_str(&format!("{indent}}}\n"));
2069 } else {
2070 js.push_str(&format!("{indent}}} else {{\n"));
2071 emit_statements(js, else_statements, depth + 1)?;
2072 js.push_str(&format!("{indent}}}\n"));
2073 }
2074 }
2075 SemanticStatement::ActionCall {
2076 name, arguments, ..
2077 } => js.push_str(&format!(
2078 "{indent}{}({});\n",
2079 name,
2080 arguments
2081 .iter()
2082 .map(emit_expr)
2083 .collect::<Result<Vec<_>, String>>()?
2084 .join(", ")
2085 )),
2086 }
2087 }
2088 Ok(())
2089}
2090
2091fn statements_declare_locals(statements: &[SemanticStatement]) -> bool {
2092 statements.iter().any(|statement| match statement {
2093 SemanticStatement::Local { .. } => true,
2094 SemanticStatement::RemoteAwait {
2095 ok_arm, err_arm, ..
2096 } => {
2097 ok_arm.binding.is_some()
2098 || err_arm.binding.is_some()
2099 || statements_declare_locals(&ok_arm.statements)
2100 || statements_declare_locals(&err_arm.statements)
2101 }
2102 SemanticStatement::If {
2103 then_statements,
2104 else_statements,
2105 ..
2106 } => {
2107 statements_declare_locals(then_statements) || statements_declare_locals(else_statements)
2108 }
2109 SemanticStatement::PrincipalMatch { arms, .. } => arms
2110 .iter()
2111 .any(|arm| arm.binding.is_some() || statements_declare_locals(&arm.statements)),
2112 SemanticStatement::Return { .. }
2113 | SemanticStatement::Assignment { .. }
2114 | SemanticStatement::FieldAssignment { .. }
2115 | SemanticStatement::LocalAssignment { .. }
2116 | SemanticStatement::ActionCall { .. }
2117 | SemanticStatement::Transition { .. }
2118 | SemanticStatement::CollectionMutation { .. }
2119 | SemanticStatement::Emit { .. } => false,
2120 })
2121}
2122
2123fn field_assignment_value(
2124 base: &str,
2125 path: &[noxid_ir::FieldPathSegment],
2126 value_js: &str,
2127) -> String {
2128 match path.split_first() {
2129 None => value_js.to_string(),
2130 Some((segment, rest)) => {
2131 let inner_base = format!("{base}[\"{}\"]", js_escape(&segment.name));
2132 format!(
2133 "{{ ...{base}, \"{}\": {} }}",
2134 js_escape(&segment.name),
2135 field_assignment_value(&inner_base, rest, value_js)
2136 )
2137 }
2138 }
2139}
2140
2141fn emit_operations(
2142 js: &mut String,
2143 operations: &[Operation],
2144 fragment: &str,
2145 owner: &str,
2146 depth: usize,
2147) -> Result<(), String> {
2148 let indent = " ".repeat(depth);
2149 for operation in operations {
2150 match operation {
2151 Operation::Text { id, marker, expression, dependencies } => js.push_str(&format!("{indent}bindText(findMarker({fragment}, \"noxid-text-{marker}\"), () => {}, {owner}, {}, \"{}\");\n", emit_expr(expression)?, emit_sources(dependencies), js_escape(id.as_str()))),
2152 Operation::Attribute { id, marker, name, expression, dependencies } => js.push_str(&format!("{indent}bindAttribute({fragment}.querySelector(\"[data-noxid-bind-{marker}]\"), \"{}\", () => {}, {owner}, {}, \"{}\");\n", js_escape(name), emit_expr(expression)?, emit_sources(dependencies), js_escape(id.as_str()))),
2153 Operation::TwoWayBinding { id, marker, name, target } => js.push_str(&format!("{indent}bindProperty({fragment}.querySelector(\"[data-noxid-two-way-{marker}]\"), \"{}\", {}, {owner}, \"{}\");\n", js_escape(name), symbol_name(target), js_escape(id.as_str()))),
2154 Operation::Event { id, marker, event, action, arguments } => js.push_str(&format!("{indent}listen({fragment}.querySelector(\"[data-noxid-event-{marker}]\"), \"{}\", {}, {owner}, \"{}\");\n", js_escape(event), event_handler_javascript(action, arguments)?, js_escape(id.as_str()))),
2155 Operation::RoutePrefetch { marker, trigger } => {
2156 let trigger = trigger.as_str();
2157 js.push_str(&format!("{indent}const $noxPrefetchTarget{marker} = {fragment}.querySelector(\"[data-noxid-prefetch-{marker}]\");\n"));
2158 js.push_str(&format!("{indent}attachCompiledPrefetch($noxPrefetchTarget{marker}, \"{trigger}\", () => $noxResourceOptions.prefetchRoute($noxPrefetchTarget{marker}.getAttribute(\"href\")), {owner}, $noxResourceOptions);\n"));
2159 }
2160 Operation::Attachment { attachment, marker } => js.push_str(&format!(
2161 "{indent}installAnimateAttachment({fragment}.querySelector(\"[data-noxid-attach-{marker}]\"), {}, {owner}, \"{}\");\n",
2162 emit_attachment_config(&attachment.config),
2163 js_escape(attachment.id.as_str()),
2164 )),
2165 Operation::Component { id, marker, target, props, handlers, prefetch, children } => {
2166 match children {
2167 None => js.push_str(&format!("{indent}mountComponent(findMarker({fragment}, \"noxid-component-{marker}\"), mount{}, {}, {}, {owner}, \"{}\");\n", target_name(target), emit_props(props, owner)?, emit_handlers(handlers), js_escape(id.as_str()))),
2168 Some(slot) => {
2169 js.push_str(&format!("{indent}mountComponent(findMarker({fragment}, \"noxid-component-{marker}\"), mount{}, Object.assign({}, {{ __noxidChildren: ($noxTarget, $noxBlockOwner) => {{\n", target_name(target), emit_props(props, owner)?));
2173 let slot_fragment = format!("$noxSlotFragment{marker}");
2174 js.push_str(&format!("{indent} const {slot_fragment} = {}.content.cloneNode(true);\n", slot.template_name));
2175 emit_operations(js, &slot.operations, &slot_fragment, "$noxBlockOwner", depth + 1)?;
2176 js.push_str(&format!("{indent} $noxTarget.appendChild({slot_fragment});\n"));
2177 js.push_str(&format!("{indent}}} }}), {}, {owner}, \"{}\");\n", emit_handlers(handlers), js_escape(id.as_str())));
2178 }
2179 }
2180 if let Some(trigger) = prefetch {
2181 js.push_str(&format!("{indent}attachCompiledPrefetch(findMarker({fragment}, \"noxid-component-{marker}\"), \"{}\", () => __noxidPrefetch{}({}, $noxResourceOptions), {owner}, $noxResourceOptions);\n", trigger.as_str(), target_name(target), emit_props(props, owner)?));
2182 }
2183 }
2184 Operation::Slot { id, marker } => js.push_str(&format!("{indent}mountSlot(findMarker({fragment}, \"noxid-slot-{marker}\"), $noxProps.__noxidChildren, {owner}, \"{}\");\n", js_escape(id.as_str()))),
2185 Operation::Conditional { id, marker, condition, dependencies, transition, template_name, operations } => {
2186 js.push_str(&format!("{indent}mountIf(findMarker({fragment}, \"noxid-if-{marker}\"), {}, {owner}, ($noxTarget, $noxBlockOwner) => {{\n", emit_source(condition, dependencies, owner)?));
2187 let nested_fragment = format!("$noxFragment{marker}");
2188 js.push_str(&format!("{indent} const {nested_fragment} = {template_name}.content.cloneNode(true);\n"));
2189 emit_operations(js, operations, &nested_fragment, "$noxBlockOwner", depth + 1)?;
2190 js.push_str(&format!("{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent}}}, \"{}\", {});\n", js_escape(id.as_str()), emit_transition(transition)));
2191 }
2192 Operation::Match {
2193 id,
2194 marker,
2195 expression,
2196 dependencies,
2197 cases,
2198 } => {
2199 js.push_str(&format!(
2200 "{indent}mountMatch(findMarker({fragment}, \"noxid-match-{marker}\"), {}, {owner}, {{\n",
2201 emit_match_source(expression, dependencies, owner)?
2202 ));
2203 for case in cases {
2204 js.push_str(&format!(
2205 "{indent} \"{}\": ($noxTarget, $noxBlockOwner, $noxMatchValue) => {{\n",
2206 js_escape(&case.variant)
2207 ));
2208 if let Some(binding) = &case.binding {
2209 js.push_str(&format!(
2210 "{indent} const {} = toSource($noxMatchValue.value);\n",
2211 binding.name
2212 ));
2213 }
2214 let nested_fragment = format!("$noxMatchFragment{marker}{}", case.variant);
2215 js.push_str(&format!(
2216 "{indent} const {nested_fragment} = {}.content.cloneNode(true);\n",
2217 case.template_name
2218 ));
2219 emit_operations(
2220 js,
2221 &case.operations,
2222 &nested_fragment,
2223 "$noxBlockOwner",
2224 depth + 2,
2225 )?;
2226 js.push_str(&format!(
2227 "{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent} }},\n"
2228 ));
2229 }
2230 js.push_str(&format!("{indent}}}, \"{}\");\n", js_escape(id.as_str())));
2231 }
2232 Operation::Stream {
2233 id,
2234 marker,
2235 expression,
2236 dependencies,
2237 cases,
2238 } => {
2239 js.push_str(&format!(
2240 "{indent}mountStream(findMarker({fragment}, \"noxid-stream-{marker}\"), {}, {owner}, {{\n",
2241 emit_source(expression, dependencies, owner)?
2242 ));
2243 for case in cases {
2244 js.push_str(&format!(
2245 "{indent} \"{}\": ($noxTarget, $noxBlockOwner, $noxStreamEvent) => {{\n",
2246 js_escape(&case.variant)
2247 ));
2248 if let Some(binding) = &case.binding {
2249 js.push_str(&format!(
2250 "{indent} const {} = toSource($noxStreamEvent.value);\n",
2251 binding.name
2252 ));
2253 }
2254 let nested_fragment = format!("$noxStreamFragment{marker}{}", case.variant);
2255 js.push_str(&format!(
2256 "{indent} const {nested_fragment} = {}.content.cloneNode(true);\n",
2257 case.template_name
2258 ));
2259 emit_operations(js, &case.operations, &nested_fragment, "$noxBlockOwner", depth + 2)?;
2260 js.push_str(&format!(
2261 "{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent} }},\n"
2262 ));
2263 }
2264 js.push_str(&format!("{indent}}}, \"{}\");\n", js_escape(id.as_str())));
2265 }
2266 Operation::For {
2267 id,
2268 marker,
2269 collection,
2270 dependencies,
2271 binding,
2272 key,
2273 template_name,
2274 operations,
2275 } => {
2276 js.push_str(&format!(
2277 "{indent}mountFor(findMarker({fragment}, \"noxid-for-{marker}\"), {}, {owner}, ({}) => {}, ($noxTarget, $noxBlockOwner, $noxItemSource) => {{\n",
2278 emit_source(collection, dependencies, owner)?,
2279 binding.name,
2280 emit_expr(key)?,
2281 ));
2282 js.push_str(&format!(
2283 "{indent} const {} = $noxItemSource;\n",
2284 binding.name
2285 ));
2286 let nested_fragment = format!("$noxForFragment{marker}");
2287 js.push_str(&format!(
2288 "{indent} const {nested_fragment} = {template_name}.content.cloneNode(true);\n"
2289 ));
2290 emit_operations(
2291 js,
2292 operations,
2293 &nested_fragment,
2294 "$noxBlockOwner",
2295 depth + 1,
2296 )?;
2297 js.push_str(&format!(
2298 "{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent}}}, \"{}\");\n",
2299 js_escape(id.as_str())
2300 ));
2301 }
2302 }
2303 }
2304 Ok(())
2305}
2306
2307fn emit_hydration_operations(
2308 js: &mut String,
2309 operations: &[Operation],
2310 root: &str,
2311 owner: &str,
2312 depth: usize,
2313) -> Result<(), String> {
2314 let indent = " ".repeat(depth);
2315 for operation in operations {
2316 match operation {
2317 Operation::Text {
2318 id,
2319 marker,
2320 expression,
2321 dependencies,
2322 } => js.push_str(&format!(
2323 "{indent}hydrateText(findMarker({root}, \"noxid-text-{marker}\"), () => {}, {owner}, {}, \"{}\");\n",
2324 emit_expr(expression)?,
2325 emit_sources(dependencies),
2326 js_escape(id.as_str()),
2327 )),
2328 Operation::Attribute {
2329 id,
2330 marker,
2331 name,
2332 expression,
2333 dependencies,
2334 } => js.push_str(&format!(
2335 "{indent}bindAttribute({root}.querySelector(\"[data-noxid-bind-{marker}]\"), \"{}\", () => {}, {owner}, {}, \"{}\");\n",
2336 js_escape(name),
2337 emit_expr(expression)?,
2338 emit_sources(dependencies),
2339 js_escape(id.as_str()),
2340 )),
2341 Operation::TwoWayBinding {
2342 id,
2343 marker,
2344 name,
2345 target,
2346 } => js.push_str(&format!(
2347 "{indent}bindProperty({root}.querySelector(\"[data-noxid-two-way-{marker}]\"), \"{}\", {}, {owner}, \"{}\");\n",
2348 js_escape(name),
2349 symbol_name(target),
2350 js_escape(id.as_str()),
2351 )),
2352 Operation::Event {
2353 id,
2354 marker,
2355 event,
2356 action,
2357 arguments,
2358 } => js.push_str(&format!(
2359 "{indent}listen({root}.querySelector(\"[data-noxid-event-{marker}]\"), \"{}\", {}, {owner}, \"{}\");\n",
2360 js_escape(event),
2361 event_handler_javascript(action, arguments)?,
2362 js_escape(id.as_str()),
2363 )),
2364 Operation::RoutePrefetch { marker, trigger } => {
2365 let trigger = trigger.as_str();
2366 js.push_str(&format!("{indent}const $noxPrefetchTarget{marker} = {root}.querySelector(\"[data-noxid-prefetch-{marker}]\");\n"));
2367 js.push_str(&format!("{indent}attachCompiledPrefetch($noxPrefetchTarget{marker}, \"{trigger}\", () => $noxResourceOptions.prefetchRoute($noxPrefetchTarget{marker}.getAttribute(\"href\")), {owner}, $noxResourceOptions);\n"));
2368 }
2369 Operation::Attachment { attachment, marker } => js.push_str(&format!(
2370 "{indent}installAnimateAttachment({root}.querySelector(\"[data-noxid-attach-{marker}]\"), {}, {owner}, \"{}\");\n",
2371 emit_attachment_config(&attachment.config),
2372 js_escape(attachment.id.as_str()),
2373 )),
2374 Operation::Component {
2375 id,
2376 marker,
2377 target,
2378 props,
2379 handlers,
2380 prefetch,
2381 children,
2382 } => {
2383 let props_javascript = match children {
2384 None => emit_props(props, owner)?,
2385 Some(slot) => {
2386 let mut closure = String::new();
2387 let slot_fragment = format!("$noxSlotFragment{marker}");
2388 closure.push_str(&format!(
2389 "Object.assign({}, {{ __noxidChildren: ($noxTarget, $noxBlockOwner, $noxHydratingSlot = false) => {{\n{indent} if ($noxHydratingSlot) {{\n",
2390 emit_props(props, owner)?
2391 ));
2392 emit_hydration_operations(
2393 &mut closure,
2394 &slot.operations,
2395 "$noxTarget",
2396 "$noxBlockOwner",
2397 depth + 2,
2398 )?;
2399 closure.push_str(&format!(
2400 "{indent} }} else {{\n{indent} const {slot_fragment} = {}.content.cloneNode(true);\n",
2401 slot.template_name
2402 ));
2403 emit_operations(
2404 &mut closure,
2405 &slot.operations,
2406 &slot_fragment,
2407 "$noxBlockOwner",
2408 depth + 2,
2409 )?;
2410 closure.push_str(&format!(
2411 "{indent} $noxTarget.appendChild({slot_fragment});\n{indent} }}\n{indent}}} }})"
2412 ));
2413 closure
2414 }
2415 };
2416 js.push_str(&format!(
2417 "{indent}hydrateComponent(findMarker({root}, \"noxid-component-{marker}\"), \"noxid-component-end-{marker}\", hydrate{}, mount{}, {}, {}, {owner}, \"{}\", $noxResourceOptions);\n",
2418 target_name(target),
2419 target_name(target),
2420 props_javascript,
2421 emit_handlers(handlers),
2422 js_escape(id.as_str()),
2423 ));
2424 if let Some(trigger) = prefetch {
2425 js.push_str(&format!("{indent}attachCompiledPrefetch(findMarker({root}, \"noxid-component-{marker}\"), \"{}\", () => __noxidPrefetch{}({}, $noxResourceOptions), {owner}, $noxResourceOptions);\n", trigger.as_str(), target_name(target), emit_props(props, owner)?));
2426 }
2427 }
2428 Operation::Slot { id, marker } => js.push_str(&format!(
2429 "{indent}hydrateSlot(findMarker({root}, \"noxid-slot-{marker}\"), \"noxid-slot-end-{marker}\", $noxProps.__noxidChildren, {owner}, \"{}\");\n",
2430 js_escape(id.as_str()),
2431 )),
2432 Operation::Conditional {
2433 id,
2434 marker,
2435 condition,
2436 dependencies,
2437 transition,
2438 template_name,
2439 operations,
2440 } => {
2441 js.push_str(&format!(
2442 "{indent}hydrateIf(findMarker({root}, \"noxid-if-{marker}\"), \"noxid-if-end-{marker}\", {}, {owner}, ($noxTarget, $noxBlockOwner, $noxHydratingBlock) => {{\n{indent} if ($noxHydratingBlock) {{\n",
2443 emit_source(condition, dependencies, owner)?,
2444 ));
2445 emit_hydration_operations(js, operations, "$noxTarget", "$noxBlockOwner", depth + 2)?;
2446 js.push_str(&format!("{indent} }} else {{\n"));
2447 let nested_fragment = format!("$noxHydratedIfFragment{marker}");
2448 js.push_str(&format!(
2449 "{indent} const {nested_fragment} = {template_name}.content.cloneNode(true);\n"
2450 ));
2451 emit_operations(js, operations, &nested_fragment, "$noxBlockOwner", depth + 2)?;
2452 js.push_str(&format!(
2453 "{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent} }}\n{indent}}}, \"{}\", {});\n",
2454 js_escape(id.as_str()),
2455 emit_transition(transition),
2456 ));
2457 }
2458 Operation::Match {
2459 id,
2460 marker,
2461 expression,
2462 dependencies,
2463 cases,
2464 } => {
2465 js.push_str(&format!(
2466 "{indent}hydrateMatch(findMarker({root}, \"noxid-match-{marker}\"), \"noxid-match-end-{marker}\", {}, {owner}, {{\n",
2467 emit_match_source(expression, dependencies, owner)?,
2468 ));
2469 for case in cases {
2470 js.push_str(&format!(
2471 "{indent} \"{}\": ($noxTarget, $noxBlockOwner, $noxMatchValue, $noxHydratingBlock) => {{\n",
2472 js_escape(&case.variant),
2473 ));
2474 if let Some(binding) = &case.binding {
2475 js.push_str(&format!(
2476 "{indent} const {} = toSource($noxMatchValue.value);\n",
2477 binding.name,
2478 ));
2479 }
2480 js.push_str(&format!("{indent} if ($noxHydratingBlock) {{\n"));
2481 emit_hydration_operations(js, &case.operations, "$noxTarget", "$noxBlockOwner", depth + 3)?;
2482 js.push_str(&format!("{indent} }} else {{\n"));
2483 let nested_fragment = format!("$noxHydratedMatchFragment{marker}{}", case.variant);
2484 js.push_str(&format!(
2485 "{indent} const {nested_fragment} = {}.content.cloneNode(true);\n",
2486 case.template_name,
2487 ));
2488 emit_operations(js, &case.operations, &nested_fragment, "$noxBlockOwner", depth + 3)?;
2489 js.push_str(&format!(
2490 "{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent} }}\n{indent} }},\n"
2491 ));
2492 }
2493 js.push_str(&format!(
2494 "{indent}}}, \"{}\");\n",
2495 js_escape(id.as_str()),
2496 ));
2497 }
2498 Operation::For {
2499 id,
2500 marker,
2501 collection,
2502 dependencies,
2503 binding,
2504 key,
2505 template_name,
2506 operations,
2507 } => {
2508 js.push_str(&format!(
2509 "{indent}hydrateFor(findMarker({root}, \"noxid-for-{marker}\"), \"noxid-for-end-{marker}\", {}, {owner}, ({}) => {}, ($noxTarget, $noxBlockOwner, $noxItemSource, $noxHydratingBlock) => {{\n",
2510 emit_source(collection, dependencies, owner)?,
2511 binding.name,
2512 emit_expr(key)?,
2513 ));
2514 js.push_str(&format!(
2515 "{indent} const {} = $noxItemSource;\n{indent} if ($noxHydratingBlock) {{\n",
2516 binding.name,
2517 ));
2518 emit_hydration_operations(js, operations, "$noxTarget", "$noxBlockOwner", depth + 2)?;
2519 js.push_str(&format!("{indent} }} else {{\n"));
2520 let nested_fragment = format!("$noxHydratedForFragment{marker}");
2521 js.push_str(&format!(
2522 "{indent} const {nested_fragment} = {template_name}.content.cloneNode(true);\n"
2523 ));
2524 emit_operations(js, operations, &nested_fragment, "$noxBlockOwner", depth + 2)?;
2525 js.push_str(&format!(
2526 "{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent} }}\n{indent}}}, \"{}\");\n",
2527 js_escape(id.as_str()),
2528 ));
2529 }
2530 Operation::Stream {
2531 id,
2532 marker,
2533 expression,
2534 dependencies,
2535 cases,
2536 } => {
2537 js.push_str(&format!(
2538 "{indent}hydrateStream(findMarker({root}, \"noxid-stream-{marker}\"), \"noxid-stream-end-{marker}\", {}, {owner}, {{\n",
2539 emit_source(expression, dependencies, owner)?,
2540 ));
2541 for case in cases {
2542 js.push_str(&format!(
2543 "{indent} \"{}\": ($noxTarget, $noxBlockOwner, $noxStreamEvent, $noxHydratingBlock) => {{\n",
2544 js_escape(&case.variant),
2545 ));
2546 if let Some(binding) = &case.binding {
2547 js.push_str(&format!(
2548 "{indent} const {} = toSource($noxStreamEvent.value);\n",
2549 binding.name,
2550 ));
2551 }
2552 js.push_str(&format!("{indent} if ($noxHydratingBlock) {{\n"));
2553 emit_hydration_operations(js, &case.operations, "$noxTarget", "$noxBlockOwner", depth + 3)?;
2554 js.push_str(&format!("{indent} }} else {{\n"));
2555 let nested_fragment = format!("$noxHydratedStreamFragment{marker}{}", case.variant);
2556 js.push_str(&format!(
2557 "{indent} const {nested_fragment} = {}.content.cloneNode(true);\n",
2558 case.template_name,
2559 ));
2560 emit_operations(js, &case.operations, &nested_fragment, "$noxBlockOwner", depth + 3)?;
2561 js.push_str(&format!(
2562 "{indent} $noxTarget.replaceChildren({nested_fragment});\n{indent} }}\n{indent} }},\n"
2563 ));
2564 }
2565 js.push_str(&format!(
2566 "{indent}}}, \"{}\");\n",
2567 js_escape(id.as_str()),
2568 ));
2569 }
2570 }
2571 }
2572 Ok(())
2573}
2574
2575fn emit_props(props: &[PropArgument], owner: &str) -> Result<String, String> {
2576 let values = props
2577 .iter()
2578 .map(|prop| {
2579 Ok(format!(
2580 "{}: {}",
2581 prop.name,
2582 emit_source(&prop.expression, &prop.dependencies, owner)?
2583 ))
2584 })
2585 .collect::<Result<Vec<_>, String>>()?
2586 .join(", ");
2587 Ok(format!("{{ {values} }}"))
2588}
2589
2590fn event_handler_javascript(
2591 action: &SemanticId,
2592 arguments: &Option<Vec<SemanticExpr>>,
2593) -> Result<String, String> {
2594 Ok(match arguments {
2595 None => symbol_name(action).to_string(),
2597 Some(arguments) => format!(
2600 "() => {}({})",
2601 symbol_name(action),
2602 arguments
2603 .iter()
2604 .map(emit_expr)
2605 .collect::<Result<Vec<_>, String>>()?
2606 .join(", ")
2607 ),
2608 })
2609}
2610
2611fn emit_handlers(handlers: &[ComponentEventHandler]) -> String {
2612 let values = handlers
2613 .iter()
2614 .map(|handler| {
2615 format!(
2616 "\"{}\": {}",
2617 js_escape(&handler.name),
2618 symbol_name(&handler.action)
2619 )
2620 })
2621 .collect::<Vec<_>>()
2622 .join(", ");
2623 format!("{{ {values} }}")
2624}
2625
2626fn emit_resource_refresh_triggers(triggers: &[ResourceRefreshTrigger]) -> String {
2627 triggers
2628 .iter()
2629 .map(|trigger| match trigger {
2630 ResourceRefreshTrigger::Focus { .. } | ResourceRefreshTrigger::Reconnect { .. } => {
2631 format!(
2632 "Object.freeze({{ kind: \"{}\", milliseconds: null }})",
2633 trigger.kind()
2634 )
2635 }
2636 ResourceRefreshTrigger::Every { milliseconds, .. } => {
2637 format!("Object.freeze({{ kind: \"every\", milliseconds: {milliseconds} }})")
2638 }
2639 })
2640 .collect::<Vec<_>>()
2641 .join(", ")
2642}
2643
2644fn emit_source(
2645 expression: &SemanticExpr,
2646 dependencies: &[SemanticId],
2647 owner: &str,
2648) -> Result<String, String> {
2649 if let SemanticExprKind::Reference(id) = &expression.kind {
2650 return Ok(symbol_name(id).into());
2651 }
2652 if dependencies.is_empty() {
2653 return emit_expr(expression);
2654 }
2655 Ok(format!(
2656 "computed(() => {}, {owner}, {})",
2657 emit_expr(expression)?,
2658 emit_sources(dependencies)
2659 ))
2660}
2661
2662fn emit_transition(transition: &Option<ConditionalTransition>) -> String {
2663 transition.as_ref().map_or_else(
2664 || "null".into(),
2665 |transition| {
2666 format!(
2667 "{{ semanticId: \"{}\", durationMilliseconds: {} }}",
2668 js_escape(transition.id.as_str()),
2669 transition.milliseconds,
2670 )
2671 },
2672 )
2673}
2674
2675fn emit_attachment_config(config: &AttachmentConfig) -> String {
2676 match config {
2677 AttachmentConfig::Animate { duration } => {
2678 format!("{{ durationMilliseconds: {} }}", duration.milliseconds)
2679 }
2680 }
2681}
2682
2683fn emit_match_source(
2684 expression: &SemanticExpr,
2685 dependencies: &[SemanticId],
2686 owner: &str,
2687) -> Result<String, String> {
2688 if !matches!(expression.ty, noxid_types::Type::Optional(_)) {
2689 return emit_source(expression, dependencies, owner);
2690 }
2691 let optional = format!(
2692 "(($noxOptional) => ($noxOptional == null ? {{ tag: \"None\" }} : {{ tag: \"Some\", value: $noxOptional }}))({})",
2693 emit_expr(expression)?,
2694 );
2695 Ok(if dependencies.is_empty() {
2696 optional
2697 } else {
2698 format!(
2699 "computed(() => {optional}, {owner}, {})",
2700 emit_sources(dependencies)
2701 )
2702 })
2703}
2704
2705pub fn emit_scenario_expression(expr: &SemanticExpr) -> Result<String, String> {
2709 let emitted = emit_expr(expr)?;
2710 Ok(if emitted.contains("$noxEqual(") {
2711 format!(
2712 "(($noxEqual) => ({}))({})",
2713 emitted,
2714 LANGUAGE_VALUE_EQUALITY_FUNCTION
2715 .trim()
2716 .strip_prefix("function $noxEqual")
2717 .map(|body| format!("function $noxEqual{body}"))
2718 .expect("equality helper is a named function")
2719 )
2720 } else {
2721 emitted
2722 })
2723}
2724
2725fn has_language_value_equality(ty: &noxid_types::Type) -> bool {
2726 use noxid_types::Type;
2727 match ty {
2728 Type::Array(_)
2729 | Type::Map(_, _)
2730 | Type::MapEntry(_, _)
2731 | Type::Result(_, _)
2732 | Type::Named(_) => true,
2733 Type::Optional(inner)
2734 | Type::Static(inner)
2735 | Type::Reactive(inner)
2736 | Type::Binding(inner) => has_language_value_equality(inner),
2737 Type::File
2740 | Type::Int
2741 | Type::String
2742 | Type::Boolean
2743 | Type::Number
2744 | Type::Float
2745 | Type::Date
2746 | Type::Function(_, _)
2747 | Type::Unknown => false,
2748 }
2749}
2750
2751fn emit_expr(expr: &SemanticExpr) -> Result<String, String> {
2758 Ok(match &expr.kind {
2759 SemanticExprKind::Int(value) => value.to_string(),
2760 SemanticExprKind::Float(value) => value.to_string(),
2761 SemanticExprKind::String(value) => format!("\"{}\"", js_escape(value)),
2762 SemanticExprKind::Boolean(value) => value.to_string(),
2763 SemanticExprKind::Array(values) => format!(
2764 "[{}]",
2765 values
2766 .iter()
2767 .map(emit_expr)
2768 .collect::<Result<Vec<_>, _>>()?
2769 .join(", ")
2770 ),
2771 SemanticExprKind::Struct { fields, .. } => format!(
2772 "{{ {} }}",
2773 fields
2774 .iter()
2775 .map(|field| {
2776 Ok(format!(
2777 "\"{}\": {}",
2778 js_escape(&field.name),
2779 emit_expr(&field.value)?
2780 ))
2781 })
2782 .collect::<Result<Vec<_>, String>>()?
2783 .join(", ")
2784 ),
2785 SemanticExprKind::FieldAccess { base, name, .. } => {
2786 format!("{}[\"{}\"]", emit_expr(base)?, js_escape(name))
2787 }
2788 SemanticExprKind::CollectionQuery {
2789 base,
2790 kind,
2791 field,
2792 value,
2793 } => {
2794 let value = value.as_deref().map(emit_expr).transpose()?;
2795 noxid_ir::collection_query_javascript(
2796 *kind,
2797 &emit_expr(base)?,
2798 field.as_ref().map(|segment| segment.name.as_str()),
2799 value.as_deref(),
2800 match &base.ty {
2801 noxid_types::Type::Map(key, _) => Some(key.as_ref()),
2802 _ => None,
2803 },
2804 )
2805 }
2806 SemanticExprKind::Call {
2807 function,
2808 name,
2809 arguments,
2810 } => format!(
2811 "__noxidValidateExternalResult(\"{}\", {}, {}({}))",
2812 js_escape(function.as_str()),
2813 external_type_descriptor(&expr.ty),
2814 name,
2815 arguments
2816 .iter()
2817 .map(emit_expr)
2818 .collect::<Result<Vec<_>, _>>()?
2819 .join(", ")
2820 ),
2821 SemanticExprKind::Reference(id)
2822 if id.as_str().starts_with("stream-use:")
2823 || id.as_str().starts_with("presence-use:") =>
2824 {
2825 format!(
2826 "{}.get().map(($noxEnvelope) => $noxEnvelope.event)",
2827 symbol_name(id)
2828 )
2829 }
2830 SemanticExprKind::Reference(id) => format!("{}.get()", symbol_name(id)),
2831 SemanticExprKind::Variant {
2832 variant, payload, ..
2833 } => {
2834 let tag = symbol_name(variant);
2835 match payload {
2836 Some(payload) => format!(
2837 "{{ tag: \"{}\", value: {} }}",
2838 js_escape(tag),
2839 emit_expr(payload)?
2840 ),
2841 None => format!("{{ tag: \"{}\" }}", js_escape(tag)),
2842 }
2843 }
2844 SemanticExprKind::Binary { left, op, right } => {
2845 if matches!(op, SemanticBinaryOp::Equal | SemanticBinaryOp::NotEqual)
2846 && has_language_value_equality(&left.ty)
2847 {
2848 let equality = format!("$noxEqual({}, {})", emit_expr(left)?, emit_expr(right)?);
2849 return Ok(if matches!(op, SemanticBinaryOp::NotEqual) {
2850 format!("(!{equality})")
2851 } else {
2852 equality
2853 });
2854 }
2855 let operator = match op {
2856 SemanticBinaryOp::Equal => "===",
2857 SemanticBinaryOp::NotEqual => "!==",
2858 SemanticBinaryOp::Coalesce => "??",
2859 other => other.as_str(),
2860 };
2861 format!("({} {operator} {})", emit_expr(left)?, emit_expr(right)?)
2862 }
2863 SemanticExprKind::Unary { op, operand } => {
2864 format!("({}{})", op.as_str(), emit_expr(operand)?)
2865 }
2866 SemanticExprKind::StringTemplate(parts) => {
2867 let mut pieces = vec!["\"\"".to_string()];
2868 for part in parts {
2869 pieces.push(match part {
2870 SemanticTemplatePart::Literal(value) => format!("\"{}\"", js_escape(value)),
2871 SemanticTemplatePart::Expression(expression) => {
2872 format!("({})", emit_expr(expression)?)
2873 }
2874 });
2875 }
2876 format!("({})", pieces.join(" + "))
2877 }
2878 SemanticExprKind::FunctionCall {
2879 function,
2880 name,
2881 arguments,
2882 } => match emit_distinct_identity_call(function, arguments)? {
2883 Some(javascript) => javascript,
2884 None => match emit_builtin_call(function, arguments)? {
2885 Some(javascript) => javascript,
2886 None => format!(
2889 "{}({})",
2890 name,
2891 arguments
2892 .iter()
2893 .map(emit_expr)
2894 .collect::<Result<Vec<_>, _>>()?
2895 .join(", ")
2896 ),
2897 },
2898 },
2899 })
2900}
2901
2902fn emit_distinct_identity_call(
2903 function: &SemanticId,
2904 arguments: &[SemanticExpr],
2905) -> Result<Option<String>, String> {
2906 if !function.is_distinct_call() {
2907 return Ok(None);
2908 }
2909 match arguments.first() {
2910 Some(argument) => emit_expr(argument).map(Some),
2911 None => Ok(Some("undefined".into())),
2912 }
2913}
2914
2915fn emit_builtin_call(
2920 function: &SemanticId,
2921 arguments: &[SemanticExpr],
2922) -> Result<Option<String>, String> {
2923 let Some(name) = function.as_str().strip_prefix("fn:@builtin.") else {
2924 return Ok(None);
2925 };
2926 let emitted = arguments
2927 .iter()
2928 .map(emit_expr)
2929 .collect::<Result<Vec<_>, _>>()?;
2930 noxid_ir::builtin_javascript(name, &emitted)
2931 .map(Some)
2932 .ok_or_else(|| noxid_ir::rejected_builtin_call(name, emitted.len()))
2933}
2934
2935fn emit_function_definitions(
2938 js: &mut String,
2939 functions: &[FunctionDefinition],
2940) -> Result<(), String> {
2941 for function in functions {
2942 js.push_str(&format!(
2943 "function {}({}) {{\n",
2944 function.name,
2945 function
2946 .parameters
2947 .iter()
2948 .map(|parameter| format!("{}Input", parameter.name))
2949 .collect::<Vec<_>>()
2950 .join(", ")
2951 ));
2952 for parameter in &function.parameters {
2953 js.push_str(&format!(
2954 " const {} = toSource({}Input);\n",
2955 parameter.name, parameter.name
2956 ));
2957 }
2958 emit_statements(js, &function.statements, 1)?;
2959 js.push_str("}\n\n");
2960 }
2961 Ok(())
2962}
2963
2964fn external_validation_prelude(program: &SemanticProgram) -> String {
2965 let schemas = program
2966 .types
2967 .iter()
2968 .chain(
2969 program
2970 .components
2971 .iter()
2972 .flat_map(|component| component.types.iter()),
2973 )
2974 .map(|definition| {
2975 format!(
2976 "\"{}\":{{{}}}",
2977 js_escape(&definition.name),
2978 definition
2979 .fields
2980 .iter()
2981 .map(|field| format!(
2982 "\"{}\":{}",
2983 js_escape(&field.name),
2984 external_type_descriptor(&field.ty)
2985 ))
2986 .collect::<Vec<_>>()
2987 .join(",")
2988 )
2989 })
2990 .collect::<Vec<_>>()
2991 .join(",");
2992 format!(
2993 r#"const __noxidExternalSchemas = Object.freeze({{{schemas}}});
2994function __noxidExternalFailure(code, symbol, expected, path, value) {{
2995 const error = new TypeError(`${{code}}: ${{symbol}} returned invalid ${{expected}} at ${{path}}`);
2996 error.code = code;
2997 error.symbol = symbol;
2998 error.expected = expected;
2999 error.path = path;
3000 error.received = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
3001 throw error;
3002}}
3003function __noxidValidateExternalValue(symbol, schema, value, path) {{
3004 switch (schema.kind) {{
3005 case "string": if (typeof value === "string") return value; break;
3006 case "boolean": if (typeof value === "boolean") return value; break;
3007 case "number": if (typeof value === "number" && Number.isFinite(value)) return value; break;
3008 case "int": if (Number.isSafeInteger(value)) return value; break;
3009 case "date": if (value instanceof Date && Number.isFinite(value.getTime())) return value; break;
3010 case "void": if (value === undefined) return value; break;
3011 case "optional": if (value === undefined || value === null) return value; return __noxidValidateExternalValue(symbol, schema.value, value, path);
3012 case "array": if (Array.isArray(value)) return Object.freeze(value.map((item, index) => __noxidValidateExternalValue(symbol, schema.item, item, `${{path}}[${{index}}]`))); break;
3013 case "map": if (value && typeof value === "object" && !Array.isArray(value)) {{ const normalized = Object.create(null); for (const [key, item] of Object.entries(value)) {{ const trustedKey = __noxidValidateExternalValue(symbol, schema.key, key, `${{path}}.<key>`); normalized[trustedKey] = __noxidValidateExternalValue(symbol, schema.value, item, `${{path}}.${{key}}`); }} return Object.freeze(normalized); }} break;
3014 case "named": {{ const fields = __noxidExternalSchemas[schema.name]; if (!fields || !value || typeof value !== "object" || Array.isArray(value)) break; const normalized = Object.create(null); for (const [name, fieldSchema] of Object.entries(fields)) normalized[name] = __noxidValidateExternalValue(symbol, fieldSchema, value[name], `${{path}}.${{name}}`); return Object.freeze(normalized); }}
3015 }}
3016 return __noxidExternalFailure("EXTERNAL_RESULT_VALIDATION_FAILED", symbol, schema.name ?? schema.kind, path, value);
3017}}
3018function __noxidValidateExternalResult(symbol, schema, value) {{
3019 if (value && typeof value.then === "function") return __noxidExternalFailure("EXTERNAL_ASYNC_RESULT_UNSUPPORTED", symbol, schema.name ?? schema.kind, "$", value);
3020 return __noxidValidateExternalValue(symbol, schema, value, "$");
3021}}
3022"#
3023 )
3024}
3025
3026fn external_type_descriptor(ty: &noxid_types::Type) -> String {
3027 use noxid_types::Type;
3028 match ty {
3029 Type::String => "{kind:\"string\"}".into(),
3030 Type::Boolean => "{kind:\"boolean\"}".into(),
3031 Type::Number | Type::Float => "{kind:\"number\"}".into(),
3032 Type::Int => "{kind:\"int\"}".into(),
3033 Type::Date => "{kind:\"date\"}".into(),
3034 Type::Array(item) => format!("{{kind:\"array\",item:{}}}", external_type_descriptor(item)),
3035 Type::Map(key, value) => format!(
3036 "{{kind:\"map\",key:{},value:{}}}",
3037 external_type_descriptor(key),
3038 external_type_descriptor(value)
3039 ),
3040 Type::Optional(value) => format!(
3041 "{{kind:\"optional\",value:{}}}",
3042 external_type_descriptor(value)
3043 ),
3044 Type::Static(value) | Type::Reactive(value) | Type::Binding(value) => {
3045 external_type_descriptor(value)
3046 }
3047 Type::Named(name) if matches!(name.as_str(), "Void" | "Undefined") => {
3048 "{kind:\"void\"}".into()
3049 }
3050 Type::Named(name) => format!("{{kind:\"named\",name:\"{}\"}}", js_escape(name)),
3051 Type::File
3052 | Type::MapEntry(_, _)
3053 | Type::Result(_, _)
3054 | Type::Function(_, _)
3055 | Type::Unknown => "{kind:\"unsupported\"}".into(),
3056 }
3057}
3058
3059fn target_name(id: &SemanticId) -> &str {
3060 id.as_str()
3061 .strip_prefix("component:")
3062 .unwrap_or(id.as_str())
3063}
3064fn symbol_name(id: &SemanticId) -> &str {
3065 if id.as_str().starts_with("presence-use:") {
3066 return "presence";
3067 }
3068 id.as_str()
3069 .rsplit('.')
3070 .next()
3071 .unwrap_or_else(|| target_name(id))
3072}
3073fn emit_sources(sources: &[SemanticId]) -> String {
3074 format!(
3075 "[{}]",
3076 sources
3077 .iter()
3078 .map(symbol_name)
3079 .collect::<Vec<_>>()
3080 .join(", ")
3081 )
3082}
3083
3084#[cfg(test)]
3085mod tests {
3086 use super::*;
3087 use noxid_ir::{StructDefinition, StructFieldDefinition};
3088 use noxid_source::Span;
3089 use noxid_types::Type;
3090 use std::process::Command;
3091
3092 fn emit_expr(expr: &SemanticExpr) -> String {
3095 super::emit_expr(expr).expect("expression has a client lowering")
3096 }
3097
3098 #[test]
3102 fn distinct_construct_and_unwrap_erase_to_the_same_plain_client_value() {
3103 let literal = SemanticExpr {
3104 kind: SemanticExprKind::String("u-1".into()),
3105 ty: Type::String,
3106 span: Span::new(0, 1),
3107 };
3108 let constructed = SemanticExpr {
3109 kind: SemanticExprKind::FunctionCall {
3110 function: SemanticId::distinct_construct("UserId"),
3111 name: "UserId".into(),
3112 arguments: vec![literal],
3113 },
3114 ty: Type::Named("UserId".into()),
3115 span: Span::new(0, 1),
3116 };
3117 let unwrapped = SemanticExpr {
3118 kind: SemanticExprKind::FunctionCall {
3119 function: SemanticId::distinct_unwrap("UserId"),
3120 name: "UserId.base".into(),
3121 arguments: vec![constructed.clone()],
3122 },
3123 ty: Type::String,
3124 span: Span::new(0, 1),
3125 };
3126 assert_eq!(emit_expr(&constructed), "\"u-1\"");
3127 assert_eq!(emit_expr(&unwrapped), "\"u-1\"");
3128 }
3129
3130 fn emit_scenario_expression(expr: &SemanticExpr) -> String {
3131 super::emit_scenario_expression(expr).expect("expression has a client lowering")
3132 }
3133
3134 #[test]
3139 fn a_builtin_with_no_lowering_fails_closed_in_the_client_emitter() {
3140 let call = client_builtin(
3141 "len",
3142 vec![
3143 SemanticExpr {
3144 kind: SemanticExprKind::String("a".into()),
3145 ty: Type::String,
3146 span: Span::new(0, 1),
3147 },
3148 SemanticExpr {
3149 kind: SemanticExprKind::String("b".into()),
3150 ty: Type::String,
3151 span: Span::new(0, 1),
3152 },
3153 ],
3154 Type::Int,
3155 );
3156 let error =
3157 super::emit_expr(&call).expect_err("a two-argument `len` has no client lowering");
3158 assert!(
3159 error.starts_with("error[BUILTIN_OVERLOAD_MISMATCH]: builtin `len`"),
3160 "{error}"
3161 );
3162 }
3163
3164 fn expression(kind: SemanticExprKind, ty: Type) -> SemanticExpr {
3165 SemanticExpr {
3166 kind,
3167 ty,
3168 span: Span::new(0, 1),
3169 }
3170 }
3171
3172 fn remote_await_component() -> ComponentDefinition {
3173 let request_parameter = ActionParameter {
3174 id: SemanticId::action_parameter("RemoteForm", "save", "request"),
3175 name: "request".into(),
3176 ty: Type::Int,
3177 type_id: None,
3178 span: Span::new(0, 1),
3179 };
3180 let remote_action = Action {
3181 id: SemanticId::action("RemoteForm", "save"),
3182 name: "save".into(),
3183 execution: ExecutionTarget::Server,
3184 parameters: vec![request_parameter.clone()],
3185 result: ActionResult {
3186 id: SemanticId::action_result("RemoteForm", "save"),
3187 ty: Type::Int,
3188 type_id: None,
3189 span: Span::new(0, 1),
3190 },
3191 capabilities: vec![],
3192 invalidation: ActionInvalidation {
3193 mode: ActionInvalidationMode::None,
3194 resources: vec![],
3195 },
3196 statements: vec![],
3197 reads: vec![],
3198 writes: vec![],
3199 calls: vec![],
3200 span: Span::new(0, 1),
3201 };
3202 let success = MatchBinding {
3203 id: SemanticId::local("RemoteForm", "submit", "saved"),
3204 name: "saved".into(),
3205 ty: Type::Int,
3206 };
3207 let failure = MatchBinding {
3208 id: SemanticId::local("RemoteForm", "submit", "error"),
3209 name: "error".into(),
3210 ty: Type::Named("RemoteError".into()),
3211 };
3212 let client_action = Action {
3213 id: SemanticId::action("RemoteForm", "submit"),
3214 name: "submit".into(),
3215 execution: ExecutionTarget::Client,
3216 parameters: vec![],
3217 result: ActionResult {
3218 id: SemanticId::action_result("RemoteForm", "submit"),
3219 ty: Type::Unknown,
3220 type_id: None,
3221 span: Span::new(0, 1),
3222 },
3223 capabilities: vec![],
3224 invalidation: ActionInvalidation {
3225 mode: ActionInvalidationMode::None,
3226 resources: vec![],
3227 },
3228 statements: vec![
3229 SemanticStatement::Local {
3230 id: SemanticId::local("RemoteForm", "submit", "request"),
3231 name: "request".into(),
3232 value: expression(SemanticExprKind::Int(3), Type::Int),
3233 span: Span::new(0, 1),
3234 },
3235 SemanticStatement::Assignment {
3236 target: SemanticId::state("RemoteForm", "savedId"),
3237 value: expression(SemanticExprKind::Int(-1), Type::Int),
3238 span: Span::new(0, 1),
3239 },
3240 SemanticStatement::RemoteAwait {
3241 binding: MatchBinding {
3242 id: SemanticId::local("RemoteForm", "submit", "outcome"),
3243 name: "outcome".into(),
3244 ty: Type::Result(
3245 Box::new(Type::Int),
3246 Box::new(Type::Named("RemoteError".into())),
3247 ),
3248 },
3249 action: remote_action.id.clone(),
3250 name: remote_action.name.clone(),
3251 arguments: vec![RemoteActionArgument {
3252 parameter: request_parameter.id.clone(),
3253 name: request_parameter.name.clone(),
3254 value: expression(
3255 SemanticExprKind::Reference(SemanticId::local(
3256 "RemoteForm",
3257 "submit",
3258 "request",
3259 )),
3260 Type::Int,
3261 ),
3262 span: Span::new(0, 1),
3263 }],
3264 ok_arm: Box::new(RemoteAwaitArm {
3265 variant: SemanticId::remote_result_variant("Ok"),
3266 name: "Ok".into(),
3267 binding: Some(success.clone()),
3268 statements: vec![SemanticStatement::Assignment {
3269 target: SemanticId::state("RemoteForm", "savedId"),
3270 value: expression(SemanticExprKind::Reference(success.id), Type::Int),
3271 span: Span::new(0, 1),
3272 }],
3273 span: Span::new(0, 1),
3274 }),
3275 err_arm: Box::new(RemoteAwaitArm {
3276 variant: SemanticId::remote_result_variant("Err"),
3277 name: "Err".into(),
3278 binding: Some(failure.clone()),
3279 statements: vec![SemanticStatement::Assignment {
3280 target: SemanticId::state("RemoteForm", "errorMessage"),
3281 value: expression(
3282 SemanticExprKind::FieldAccess {
3283 base: Box::new(expression(
3284 SemanticExprKind::Reference(failure.id),
3285 Type::Named("RemoteError".into()),
3286 )),
3287 field: SemanticId::external_field("RemoteError", "message"),
3288 name: "message".into(),
3289 },
3290 Type::String,
3291 ),
3292 span: Span::new(0, 1),
3293 }],
3294 span: Span::new(0, 1),
3295 }),
3296 span: Span::new(0, 1),
3297 },
3298 SemanticStatement::Assignment {
3299 target: SemanticId::state("RemoteForm", "finished"),
3300 value: expression(SemanticExprKind::Boolean(true), Type::Boolean),
3301 span: Span::new(0, 1),
3302 },
3303 ],
3304 reads: vec![],
3305 writes: vec![],
3306 calls: vec![remote_action.id.clone()],
3307 span: Span::new(0, 1),
3308 };
3309 ComponentDefinition {
3310 id: SemanticId::component("RemoteForm"),
3311 name: "RemoteForm".into(),
3312 route_metadata: None,
3313 route_render: None,
3314 render: ComponentRenderPolicy {
3315 id: SemanticId::component_render("RemoteForm"),
3316 mode: ComponentRenderMode::Universal,
3317 hydration: HydrationMode::Eager,
3318 span: Span::new(0, 1),
3319 },
3320 route_query: vec![],
3321 middleware: vec![],
3322 capabilities: vec![],
3323 props: vec![],
3324 events: vec![],
3325 context_uses: vec![],
3326 context_providers: vec![],
3327 types: vec![],
3328 distinct_types: vec![],
3329 machines: vec![],
3330 states: vec![],
3331 computed: vec![],
3332 loaders: vec![],
3333 resources: vec![],
3334 presence: None,
3335 streams: vec![],
3336 agents: vec![],
3337 actions: vec![remote_action, client_action],
3338 lifecycle: None,
3339 effects: vec![],
3340 behaviors: vec![],
3341 regions: vec![],
3342 intent: None,
3343 invariants: vec![],
3344 requirements: vec![],
3345 scenarios: vec![],
3346 view: vec![],
3347 style: None,
3348 span: Span::new(0, 1),
3349 }
3350 }
3351
3352 #[test]
3353 fn remote_await_commits_optimistic_and_continuation_transactions_around_boundary() {
3354 let mut generated = String::new();
3355 emit_hmr_action_factory(
3356 &mut generated,
3357 &SemanticProgram {
3358 imports: vec![],
3359 functions: vec![],
3360 external_modules: vec![],
3361 contexts: vec![],
3362 types: vec![],
3363 distinct_types: vec![],
3364 resources: vec![],
3365 streams: vec![],
3366 agents: vec![],
3367 endpoints: vec![],
3368 tasks: vec![],
3369 queues: vec![],
3370 models: vec![],
3371 components: vec![],
3372 },
3373 &remote_await_component(),
3374 )
3375 .expect("component has a client lowering");
3376 let generated = generated.replace("export ", "");
3377 let script = format!(
3378 r#"const transactions = [];
3379function runAction(id, _owner, callback) {{ transactions.push(id); return callback(); }}
3380function toSource(value) {{ return {{ get() {{ return value; }} }}; }}
3381function source(initial) {{ let value = initial; return {{ get() {{ return value; }}, set(next) {{ value = next; }} }}; }}
3382{generated}
3383let resolveBoundary;
3384const savedId = source(0);
3385const errorMessage = source("");
3386const finished = source(false);
3387const pending = new Promise((resolve) => {{ resolveBoundary = resolve; }});
3388const actions = __noxidCreateRemoteFormActions({{ $noxOwner: null, $noxEmit() {{}}, savedId, errorMessage, finished, $noxExecuteBoundary(descriptor) {{
3389 if (descriptor.id !== "action:RemoteForm.save" || descriptor.arguments[0].value !== 3) throw new Error("invalid descriptor");
3390 return pending;
3391}} }});
3392const submission = actions.submit();
3393if (savedId.get() !== -1 || finished.get() || transactions.length !== 1) throw new Error("optimistic transaction did not commit before await");
3394resolveBoundary(7);
3395await submission;
3396if (savedId.get() !== 7 || !finished.get() || transactions.length !== 2) throw new Error("Ok continuation did not run in exactly one fresh transaction");
3397
3398transactions.length = 0;
3399savedId.set(0); errorMessage.set(""); finished.set(false);
3400const rejected = __noxidCreateRemoteFormActions({{ $noxOwner: null, $noxEmit() {{}}, savedId, errorMessage, finished, async $noxExecuteBoundary() {{ const error = new Error("already exists"); error.code = "CONFLICT"; throw error; }} }});
3401await rejected.submit();
3402if (savedId.get() !== -1 || errorMessage.get() !== "already exists" || !finished.get()) throw new Error("Err continuation did not consume the closed RemoteError value");
3403if (transactions.length !== 2) throw new Error("Err path did not preserve the two-transaction contract");
3404
3405transactions.length = 0;
3406savedId.set(0); errorMessage.set(""); finished.set(false);
3407const unavailable = __noxidCreateRemoteFormActions({{ $noxOwner: null, $noxEmit() {{}}, savedId, errorMessage, finished }});
3408await unavailable.submit();
3409if (savedId.get() !== -1 || !errorMessage.get().includes("No host executor is configured") || !finished.get()) throw new Error("missing boundary executor escaped the typed Err channel");
3410if (transactions.length !== 2) throw new Error("unavailable boundary did not preserve the two-transaction contract");
3411"#,
3412 );
3413 let output = Command::new("node")
3414 .args(["--input-type=module", "-e", &script])
3415 .output()
3416 .expect("node must execute generated remote-await actions");
3417 assert!(
3418 output.status.success(),
3419 "generated:\n{generated}\nstderr:\n{}",
3420 String::from_utf8_lossy(&output.stderr)
3421 );
3422 }
3423
3424 #[test]
3425 fn remote_await_arm_transitions_keep_the_runtime_helper_import() {
3426 let mut component = remote_await_component();
3427 let submit = component
3428 .actions
3429 .iter_mut()
3430 .find(|action| action.name == "submit")
3431 .unwrap();
3432 let remote_await = submit
3433 .statements
3434 .iter_mut()
3435 .find_map(|statement| match statement {
3436 SemanticStatement::RemoteAwait { ok_arm, .. } => Some(ok_arm),
3437 SemanticStatement::Return { .. }
3438 | SemanticStatement::Assignment { .. }
3439 | SemanticStatement::FieldAssignment { .. }
3440 | SemanticStatement::Local { .. }
3441 | SemanticStatement::LocalAssignment { .. }
3442 | SemanticStatement::If { .. }
3443 | SemanticStatement::ActionCall { .. }
3444 | SemanticStatement::Transition { .. }
3445 | SemanticStatement::CollectionMutation { .. }
3446 | SemanticStatement::PrincipalMatch { .. }
3447 | SemanticStatement::Emit { .. } => None,
3448 })
3449 .unwrap();
3450 remote_await.statements = vec![SemanticStatement::Transition {
3451 target: SemanticId::state("RemoteForm", "phase"),
3452 value: expression(
3453 SemanticExprKind::Variant {
3454 machine: SemanticId::machine("RemoteForm", "Phase"),
3455 variant: SemanticId::variant("RemoteForm", "Phase", "Ready"),
3456 payload: None,
3457 },
3458 Type::Named("Phase".into()),
3459 ),
3460 machine: SemanticId::machine("RemoteForm", "Phase"),
3461 event: "submit".into(),
3462 allowed: vec![("Idle".into(), "Ready".into())],
3463 span: Span::new(0, 1),
3464 }];
3465
3466 let mut imports = BTreeSet::new();
3467 collect_component_runtime_imports(&component, &[], false, &mut imports);
3468 assert!(imports.contains("transitionMachine"));
3469 }
3470
3471 fn client_builtin(name: &str, arguments: Vec<SemanticExpr>, ty: Type) -> SemanticExpr {
3472 SemanticExpr {
3473 kind: SemanticExprKind::FunctionCall {
3474 function: SemanticId::function(&format!("@builtin.{name}")),
3475 name: name.into(),
3476 arguments,
3477 },
3478 ty,
3479 span: Span::new(0, 1),
3480 }
3481 }
3482
3483 #[test]
3484 fn emits_every_scalar_builtin_with_reactive_client_arguments() {
3485 let string = || SemanticExpr {
3486 kind: SemanticExprKind::Reference(SemanticId::state("C", "name")),
3487 ty: Type::String,
3488 span: Span::new(0, 1),
3489 };
3490 let int = |value| SemanticExpr {
3491 kind: SemanticExprKind::Int(value),
3492 ty: Type::Int,
3493 span: Span::new(0, 1),
3494 };
3495 let float = |value| SemanticExpr {
3496 kind: SemanticExprKind::Float(value),
3497 ty: Type::Float,
3498 span: Span::new(0, 1),
3499 };
3500 let cases = [
3501 (
3502 client_builtin("len", vec![string()], Type::Int),
3503 "name.get().length",
3504 ),
3505 (
3506 client_builtin(
3507 "contains",
3508 vec![
3509 string(),
3510 SemanticExpr {
3511 kind: SemanticExprKind::String("a".into()),
3512 ty: Type::String,
3513 span: Span::new(0, 1),
3514 },
3515 ],
3516 Type::Boolean,
3517 ),
3518 "name.get().includes(\"a\")",
3519 ),
3520 (
3521 client_builtin(
3522 "startsWith",
3523 vec![
3524 string(),
3525 SemanticExpr {
3526 kind: SemanticExprKind::String("A".into()),
3527 ty: Type::String,
3528 span: Span::new(0, 1),
3529 },
3530 ],
3531 Type::Boolean,
3532 ),
3533 "name.get().startsWith(\"A\")",
3534 ),
3535 (
3536 client_builtin("trim", vec![string()], Type::String),
3537 "name.get().trim()",
3538 ),
3539 (
3540 client_builtin("lower", vec![string()], Type::String),
3541 "name.get().toLowerCase()",
3542 ),
3543 (
3544 client_builtin("upper", vec![string()], Type::String),
3545 "name.get().toUpperCase()",
3546 ),
3547 (
3548 client_builtin("min", vec![int(2), int(3)], Type::Int),
3549 "Math.min(2, 3)",
3550 ),
3551 (
3552 client_builtin("max", vec![float(2.5), float(3.5)], Type::Float),
3553 "Math.max(2.5, 3.5)",
3554 ),
3555 (
3556 client_builtin("abs", vec![int(-2)], Type::Int),
3557 "Math.abs(-2)",
3558 ),
3559 (
3560 client_builtin("round", vec![float(2.5)], Type::Int),
3561 "Math.round(2.5)",
3562 ),
3563 (
3564 client_builtin("floor", vec![float(2.5)], Type::Int),
3565 "Math.floor(2.5)",
3566 ),
3567 (
3568 client_builtin("ceil", vec![float(2.5)], Type::Int),
3569 "Math.ceil(2.5)",
3570 ),
3571 (client_builtin("toFloat", vec![int(2)], Type::Float), "(2)"),
3572 (
3573 client_builtin("toInt", vec![float(2.5)], Type::Int),
3574 "Math.trunc(2.5)",
3575 ),
3576 (
3577 client_builtin("toString", vec![int(2)], Type::String),
3578 "String(2)",
3579 ),
3580 ];
3581 for (expression, expected) in cases {
3582 assert_eq!(emit_expr(&expression), expected);
3583 }
3584
3585 let shadowing_user_function = SemanticExpr {
3586 kind: SemanticExprKind::FunctionCall {
3587 function: SemanticId::function("len"),
3588 name: "len".into(),
3589 arguments: vec![string()],
3590 },
3591 ty: Type::Int,
3592 span: Span::new(0, 1),
3593 };
3594 assert_eq!(emit_expr(&shadowing_user_function), "len(name.get())");
3595 }
3596
3597 fn empty_program() -> SemanticProgram {
3598 SemanticProgram {
3599 imports: vec![],
3600 functions: vec![],
3601 external_modules: vec![],
3602 contexts: vec![],
3603 types: vec![StructDefinition {
3604 id: SemanticId::global_type("User"),
3605 name: "User".into(),
3606 fields: vec![
3607 StructFieldDefinition {
3608 id: SemanticId::global_type_field("User", "name"),
3609 name: "name".into(),
3610 ty: Type::String,
3611 span: Span::new(0, 0),
3612 },
3613 StructFieldDefinition {
3614 id: SemanticId::global_type_field("User", "scores"),
3615 name: "scores".into(),
3616 ty: Type::Array(Box::new(Type::Int)),
3617 span: Span::new(0, 0),
3618 },
3619 ],
3620 span: Span::new(0, 0),
3621 }],
3622 distinct_types: vec![],
3623 resources: vec![],
3624 streams: vec![],
3625 agents: vec![],
3626 endpoints: vec![],
3627 tasks: vec![],
3628 queues: vec![],
3629 models: vec![],
3630 components: vec![],
3631 }
3632 }
3633
3634 #[test]
3635 fn optional_matches_emit_reactive_tagged_sources_without_changing_machine_sources() {
3636 let dependency = SemanticId::state("C", "page");
3637 let optional = SemanticExpr {
3638 kind: SemanticExprKind::Reference(dependency.clone()),
3639 ty: Type::Optional(Box::new(Type::Int)),
3640 span: Span::new(0, 0),
3641 };
3642 assert_eq!(
3643 emit_match_source(&optional, std::slice::from_ref(&dependency), "$noxOwner")
3644 .expect("optional match source emits"),
3645 "computed(() => (($noxOptional) => ($noxOptional == null ? { tag: \"None\" } : { tag: \"Some\", value: $noxOptional }))(page.get()), $noxOwner, [page])"
3646 );
3647 assert!(match_source_requires_computed(
3648 &optional,
3649 std::slice::from_ref(&dependency)
3650 ));
3651
3652 let machine = SemanticExpr {
3653 kind: SemanticExprKind::Reference(dependency.clone()),
3654 ty: Type::Named("PageState".into()),
3655 span: Span::new(0, 0),
3656 };
3657 assert_eq!(
3658 emit_match_source(&machine, std::slice::from_ref(&dependency), "$noxOwner")
3659 .expect("machine match source emits"),
3660 "page"
3661 );
3662 assert!(!match_source_requires_computed(&machine, &[dependency]));
3663 }
3664
3665 #[test]
3666 fn imported_results_are_validated_normalized_and_async_safe() {
3667 let mut script = external_validation_prelude(&empty_program());
3668 script.push_str(
3669 r#"
3670const user = __noxidValidateExternalResult("external-function:legacy.load", {kind:"named",name:"User"}, {name:"Ada",scores:[1,2],ignored:true});
3671if (user.name !== "Ada" || user.ignored !== undefined || !Object.isFrozen(user) || !Object.isFrozen(user.scores)) process.exit(2);
3672try { __noxidValidateExternalResult("external-function:legacy.bad", {kind:"int"}, "1"); process.exit(3); }
3673catch (error) { if (error.code !== "EXTERNAL_RESULT_VALIDATION_FAILED" || error.symbol !== "external-function:legacy.bad") process.exit(4); }
3674try { __noxidValidateExternalResult("external-function:legacy.async", {kind:"string"}, Promise.resolve("value")); process.exit(5); }
3675catch (error) { if (error.code !== "EXTERNAL_ASYNC_RESULT_UNSUPPORTED") process.exit(6); }
3676"#,
3677 );
3678 let output = Command::new("node")
3679 .args(["--input-type=module", "-e", &script])
3680 .output()
3681 .expect("node must execute generated validation");
3682 assert!(
3683 output.status.success(),
3684 "{}",
3685 String::from_utf8_lossy(&output.stderr)
3686 );
3687 }
3688
3689 #[test]
3690 fn client_compound_equality_executes_recursive_value_semantics() {
3691 fn row(id: i64) -> SemanticExpr {
3692 SemanticExpr {
3693 kind: SemanticExprKind::Struct {
3694 definition: SemanticId::global_type("Row"),
3695 fields: vec![StructFieldValue {
3696 field: SemanticId::global_type_field("Row", "id"),
3697 name: "id".into(),
3698 value: SemanticExpr {
3699 kind: SemanticExprKind::Int(id),
3700 ty: Type::Int,
3701 span: Span::new(0, 0),
3702 },
3703 span: Span::new(0, 0),
3704 }],
3705 },
3706 ty: Type::Named("Row".into()),
3707 span: Span::new(0, 0),
3708 }
3709 }
3710 fn rows(values: &[i64]) -> SemanticExpr {
3711 SemanticExpr {
3712 kind: SemanticExprKind::Array(values.iter().copied().map(row).collect()),
3713 ty: Type::Array(Box::new(Type::Named("Row".into()))),
3714 span: Span::new(0, 0),
3715 }
3716 }
3717 let same = SemanticExpr {
3718 kind: SemanticExprKind::Binary {
3719 left: Box::new(rows(&[1, 2])),
3720 op: SemanticBinaryOp::Equal,
3721 right: Box::new(rows(&[1, 2])),
3722 },
3723 ty: Type::Boolean,
3724 span: Span::new(0, 0),
3725 };
3726 let different = SemanticExpr {
3727 kind: SemanticExprKind::Binary {
3728 left: Box::new(rows(&[1, 2])),
3729 op: SemanticBinaryOp::NotEqual,
3730 right: Box::new(rows(&[1, 3])),
3731 },
3732 ty: Type::Boolean,
3733 span: Span::new(0, 0),
3734 };
3735 let primitive = SemanticExpr {
3736 kind: SemanticExprKind::Binary {
3737 left: Box::new(SemanticExpr {
3738 kind: SemanticExprKind::Int(1),
3739 ty: Type::Int,
3740 span: Span::new(0, 0),
3741 }),
3742 op: SemanticBinaryOp::Equal,
3743 right: Box::new(SemanticExpr {
3744 kind: SemanticExprKind::Int(1),
3745 ty: Type::Int,
3746 span: Span::new(0, 0),
3747 }),
3748 },
3749 ty: Type::Boolean,
3750 span: Span::new(0, 0),
3751 };
3752 assert_eq!(emit_expr(&primitive), "(1 === 1)");
3753 let script = format!(
3754 "if (!({}) || !({})) process.exit(1);",
3755 emit_scenario_expression(&same),
3756 emit_scenario_expression(&different)
3757 );
3758 let output = Command::new("node")
3759 .args(["--input-type=module", "-e", &script])
3760 .output()
3761 .expect("node must execute generated compound equality");
3762 assert!(
3763 output.status.success(),
3764 "{}",
3765 String::from_utf8_lossy(&output.stderr)
3766 );
3767 }
3768}