@affectively/aeon-pages 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +625 -0
  3. package/examples/basic/aeon.config.ts +39 -0
  4. package/examples/basic/components/Cursor.tsx +86 -0
  5. package/examples/basic/components/OfflineIndicator.tsx +103 -0
  6. package/examples/basic/components/PresenceBar.tsx +77 -0
  7. package/examples/basic/package.json +20 -0
  8. package/examples/basic/pages/index.tsx +80 -0
  9. package/package.json +101 -0
  10. package/packages/analytics/README.md +309 -0
  11. package/packages/analytics/build.ts +35 -0
  12. package/packages/analytics/package.json +50 -0
  13. package/packages/analytics/src/click-tracker.ts +368 -0
  14. package/packages/analytics/src/context-bridge.ts +319 -0
  15. package/packages/analytics/src/data-layer.ts +302 -0
  16. package/packages/analytics/src/gtm-loader.ts +239 -0
  17. package/packages/analytics/src/index.ts +230 -0
  18. package/packages/analytics/src/merkle-tree.ts +489 -0
  19. package/packages/analytics/src/provider.tsx +300 -0
  20. package/packages/analytics/src/types.ts +320 -0
  21. package/packages/analytics/src/use-analytics.ts +296 -0
  22. package/packages/analytics/tsconfig.json +19 -0
  23. package/packages/benchmarks/src/benchmark.test.ts +691 -0
  24. package/packages/cli/dist/index.js +61899 -0
  25. package/packages/cli/package.json +43 -0
  26. package/packages/cli/src/commands/build.test.ts +682 -0
  27. package/packages/cli/src/commands/build.ts +890 -0
  28. package/packages/cli/src/commands/dev.ts +473 -0
  29. package/packages/cli/src/commands/init.ts +409 -0
  30. package/packages/cli/src/commands/start.ts +297 -0
  31. package/packages/cli/src/index.ts +105 -0
  32. package/packages/directives/src/use-aeon.ts +272 -0
  33. package/packages/mcp-server/package.json +51 -0
  34. package/packages/mcp-server/src/index.ts +178 -0
  35. package/packages/mcp-server/src/resources.ts +346 -0
  36. package/packages/mcp-server/src/tools/index.ts +36 -0
  37. package/packages/mcp-server/src/tools/navigation.ts +545 -0
  38. package/packages/mcp-server/tsconfig.json +21 -0
  39. package/packages/react/package.json +40 -0
  40. package/packages/react/src/Link.tsx +388 -0
  41. package/packages/react/src/components/InstallPrompt.tsx +286 -0
  42. package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
  43. package/packages/react/src/components/PushNotifications.tsx +453 -0
  44. package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
  45. package/packages/react/src/hooks/useConflicts.ts +277 -0
  46. package/packages/react/src/hooks/useNetworkState.ts +209 -0
  47. package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
  48. package/packages/react/src/hooks/useServiceWorker.ts +278 -0
  49. package/packages/react/src/hooks.ts +195 -0
  50. package/packages/react/src/index.ts +151 -0
  51. package/packages/react/src/provider.tsx +467 -0
  52. package/packages/react/tsconfig.json +19 -0
  53. package/packages/runtime/README.md +399 -0
  54. package/packages/runtime/build.ts +48 -0
  55. package/packages/runtime/package.json +71 -0
  56. package/packages/runtime/schema.sql +40 -0
  57. package/packages/runtime/src/api-routes.ts +465 -0
  58. package/packages/runtime/src/benchmark.ts +171 -0
  59. package/packages/runtime/src/cache.ts +479 -0
  60. package/packages/runtime/src/durable-object.ts +1341 -0
  61. package/packages/runtime/src/index.ts +360 -0
  62. package/packages/runtime/src/navigation.test.ts +421 -0
  63. package/packages/runtime/src/navigation.ts +422 -0
  64. package/packages/runtime/src/nextjs-adapter.ts +272 -0
  65. package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
  66. package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
  67. package/packages/runtime/src/offline/encryption.test.ts +412 -0
  68. package/packages/runtime/src/offline/encryption.ts +397 -0
  69. package/packages/runtime/src/offline/types.ts +465 -0
  70. package/packages/runtime/src/predictor.ts +371 -0
  71. package/packages/runtime/src/registry.ts +351 -0
  72. package/packages/runtime/src/router/context-extractor.ts +661 -0
  73. package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
  74. package/packages/runtime/src/router/esi-control.ts +541 -0
  75. package/packages/runtime/src/router/esi-cyrano.ts +779 -0
  76. package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
  77. package/packages/runtime/src/router/esi-react.tsx +1065 -0
  78. package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
  79. package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
  80. package/packages/runtime/src/router/esi-translate.ts +503 -0
  81. package/packages/runtime/src/router/esi.ts +666 -0
  82. package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
  83. package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
  84. package/packages/runtime/src/router/index.ts +298 -0
  85. package/packages/runtime/src/router/merkle-capability.ts +473 -0
  86. package/packages/runtime/src/router/speculation.ts +451 -0
  87. package/packages/runtime/src/router/types.ts +630 -0
  88. package/packages/runtime/src/router.test.ts +470 -0
  89. package/packages/runtime/src/router.ts +302 -0
  90. package/packages/runtime/src/server.ts +481 -0
  91. package/packages/runtime/src/service-worker-push.ts +319 -0
  92. package/packages/runtime/src/service-worker.ts +553 -0
  93. package/packages/runtime/src/skeleton-hydrate.ts +237 -0
  94. package/packages/runtime/src/speculation.test.ts +389 -0
  95. package/packages/runtime/src/speculation.ts +486 -0
  96. package/packages/runtime/src/storage.test.ts +1297 -0
  97. package/packages/runtime/src/storage.ts +1048 -0
  98. package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
  99. package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
  100. package/packages/runtime/src/sync/coordinator.test.ts +608 -0
  101. package/packages/runtime/src/sync/coordinator.ts +596 -0
  102. package/packages/runtime/src/tree-compiler.ts +295 -0
  103. package/packages/runtime/src/types.ts +728 -0
  104. package/packages/runtime/src/worker.ts +327 -0
  105. package/packages/runtime/tsconfig.json +20 -0
  106. package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
  107. package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
  108. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
  109. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
  110. package/packages/runtime/wasm/package.json +21 -0
  111. package/packages/runtime/wrangler.toml +41 -0
  112. package/packages/runtime-wasm/Cargo.lock +436 -0
  113. package/packages/runtime-wasm/Cargo.toml +29 -0
  114. package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
  115. package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
  116. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
  117. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
  118. package/packages/runtime-wasm/pkg/package.json +21 -0
  119. package/packages/runtime-wasm/src/hydrate.rs +352 -0
  120. package/packages/runtime-wasm/src/lib.rs +191 -0
  121. package/packages/runtime-wasm/src/render.rs +629 -0
  122. package/packages/runtime-wasm/src/router.rs +298 -0
  123. package/packages/runtime-wasm/src/skeleton.rs +430 -0
  124. package/rfcs/RFC-001-ZERO-DEPENDENCY-RENDERING.md +1446 -0
@@ -0,0 +1,629 @@
1
+ //! Zero-Dependency Page Rendering
2
+ //!
3
+ //! WASM-based renderer that walks component trees and produces
4
+ //! self-contained HTML with inline CSS, assets, and fonts.
5
+
6
+ use wasm_bindgen::prelude::*;
7
+ use serde::{Deserialize, Serialize};
8
+ use std::collections::{HashMap, HashSet};
9
+
10
+ /// CSS Manifest for on-demand CSS generation
11
+ #[wasm_bindgen]
12
+ #[derive(Clone, Debug, Serialize, Deserialize)]
13
+ pub struct CSSManifest {
14
+ /// Version of the manifest
15
+ version: String,
16
+ /// Pre-computed CSS rules keyed by class name
17
+ rules: HashMap<String, Vec<CSSRule>>,
18
+ /// Critical CSS (always included)
19
+ critical: String,
20
+ }
21
+
22
+ #[derive(Clone, Debug, Serialize, Deserialize)]
23
+ pub struct CSSRule {
24
+ selector: String,
25
+ declarations: String,
26
+ media_query: Option<String>,
27
+ }
28
+
29
+ #[wasm_bindgen]
30
+ impl CSSManifest {
31
+ #[wasm_bindgen(constructor)]
32
+ pub fn new(critical: String) -> Self {
33
+ Self {
34
+ version: "1.0.0".to_string(),
35
+ rules: HashMap::new(),
36
+ critical,
37
+ }
38
+ }
39
+
40
+ /// Load manifest from JSON
41
+ pub fn from_json(json: &str) -> Result<CSSManifest, JsValue> {
42
+ serde_json::from_str(json)
43
+ .map_err(|e| JsValue::from_str(&format!("Failed to parse CSS manifest: {}", e)))
44
+ }
45
+
46
+ /// Get critical CSS
47
+ #[wasm_bindgen(getter)]
48
+ pub fn critical(&self) -> String {
49
+ self.critical.clone()
50
+ }
51
+ }
52
+
53
+ /// Asset Manifest for inlining images/SVGs
54
+ #[wasm_bindgen]
55
+ #[derive(Clone, Debug, Serialize, Deserialize)]
56
+ pub struct AssetManifest {
57
+ /// Version of the manifest
58
+ version: String,
59
+ /// Asset entries keyed by original path
60
+ assets: HashMap<String, AssetEntry>,
61
+ }
62
+
63
+ #[derive(Clone, Debug, Serialize, Deserialize)]
64
+ pub struct AssetEntry {
65
+ /// Original file path
66
+ original_path: String,
67
+ /// Base64 data URI
68
+ data_uri: String,
69
+ /// File size in bytes
70
+ size: usize,
71
+ /// File format (svg, png, jpg, etc.)
72
+ format: String,
73
+ }
74
+
75
+ #[wasm_bindgen]
76
+ impl AssetManifest {
77
+ #[wasm_bindgen(constructor)]
78
+ pub fn new() -> Self {
79
+ Self {
80
+ version: "1.0.0".to_string(),
81
+ assets: HashMap::new(),
82
+ }
83
+ }
84
+
85
+ /// Load manifest from JSON
86
+ pub fn from_json(json: &str) -> Result<AssetManifest, JsValue> {
87
+ serde_json::from_str(json)
88
+ .map_err(|e| JsValue::from_str(&format!("Failed to parse asset manifest: {}", e)))
89
+ }
90
+
91
+ /// Get data URI for an asset path
92
+ pub fn get_data_uri(&self, path: &str) -> Option<String> {
93
+ self.assets.get(path).map(|e| e.data_uri.clone())
94
+ }
95
+ }
96
+
97
+ impl Default for AssetManifest {
98
+ fn default() -> Self {
99
+ Self::new()
100
+ }
101
+ }
102
+
103
+ /// Font Manifest for embedding fonts
104
+ #[wasm_bindgen]
105
+ #[derive(Clone, Debug, Serialize, Deserialize)]
106
+ pub struct FontManifest {
107
+ /// Version of the manifest
108
+ version: String,
109
+ /// Font entries keyed by family name
110
+ fonts: HashMap<String, Vec<FontEntry>>,
111
+ /// Pre-generated @font-face CSS
112
+ font_face_css: String,
113
+ }
114
+
115
+ #[derive(Clone, Debug, Serialize, Deserialize)]
116
+ pub struct FontEntry {
117
+ /// Font family name
118
+ family: String,
119
+ /// Font weight (400, 700, etc.)
120
+ weight: u32,
121
+ /// Font style (normal, italic)
122
+ style: String,
123
+ /// Base64 data URI
124
+ data_uri: String,
125
+ /// Font format (woff2, woff, etc.)
126
+ format: String,
127
+ }
128
+
129
+ #[wasm_bindgen]
130
+ impl FontManifest {
131
+ #[wasm_bindgen(constructor)]
132
+ pub fn new() -> Self {
133
+ Self {
134
+ version: "1.0.0".to_string(),
135
+ fonts: HashMap::new(),
136
+ font_face_css: String::new(),
137
+ }
138
+ }
139
+
140
+ /// Load manifest from JSON
141
+ pub fn from_json(json: &str) -> Result<FontManifest, JsValue> {
142
+ serde_json::from_str(json)
143
+ .map_err(|e| JsValue::from_str(&format!("Failed to parse font manifest: {}", e)))
144
+ }
145
+
146
+ /// Get @font-face CSS
147
+ #[wasm_bindgen(getter)]
148
+ pub fn font_face_css(&self) -> String {
149
+ self.font_face_css.clone()
150
+ }
151
+ }
152
+
153
+ impl Default for FontManifest {
154
+ fn default() -> Self {
155
+ Self::new()
156
+ }
157
+ }
158
+
159
+ /// Render context containing manifests and collected data
160
+ #[wasm_bindgen]
161
+ pub struct RenderContext {
162
+ css_manifest: CSSManifest,
163
+ asset_manifest: AssetManifest,
164
+ font_manifest: FontManifest,
165
+ collected_classes: HashSet<String>,
166
+ interactive_nodes: Vec<InteractiveNode>,
167
+ }
168
+
169
+ #[derive(Clone, Debug, Serialize, Deserialize)]
170
+ pub struct InteractiveNode {
171
+ id: String,
172
+ component_type: String,
173
+ hydration_mode: String,
174
+ }
175
+
176
+ #[wasm_bindgen]
177
+ impl RenderContext {
178
+ #[wasm_bindgen(constructor)]
179
+ pub fn new(
180
+ css_manifest_json: &str,
181
+ asset_manifest_json: &str,
182
+ font_manifest_json: &str,
183
+ ) -> Result<RenderContext, JsValue> {
184
+ let css_manifest = CSSManifest::from_json(css_manifest_json)?;
185
+ let asset_manifest = AssetManifest::from_json(asset_manifest_json)?;
186
+ let font_manifest = FontManifest::from_json(font_manifest_json)?;
187
+
188
+ Ok(Self {
189
+ css_manifest,
190
+ asset_manifest,
191
+ font_manifest,
192
+ collected_classes: HashSet::new(),
193
+ interactive_nodes: Vec::new(),
194
+ })
195
+ }
196
+
197
+ /// Get collected CSS classes as JSON array
198
+ pub fn get_collected_classes(&self) -> String {
199
+ let classes: Vec<&String> = self.collected_classes.iter().collect();
200
+ serde_json::to_string(&classes).unwrap_or_else(|_| "[]".to_string())
201
+ }
202
+
203
+ /// Get interactive nodes as JSON array
204
+ pub fn get_interactive_nodes(&self) -> String {
205
+ serde_json::to_string(&self.interactive_nodes).unwrap_or_else(|_| "[]".to_string())
206
+ }
207
+ }
208
+
209
+ /// Component tree node for walking
210
+ #[derive(Clone, Debug, Serialize, Deserialize)]
211
+ pub struct TreeNode {
212
+ #[serde(rename = "type")]
213
+ node_type: String,
214
+ props: Option<HashMap<String, serde_json::Value>>,
215
+ children: Option<Vec<TreeChild>>,
216
+ }
217
+
218
+ #[derive(Clone, Debug, Serialize, Deserialize)]
219
+ #[serde(untagged)]
220
+ pub enum TreeChild {
221
+ Text(String),
222
+ Node(Box<TreeNode>),
223
+ }
224
+
225
+ /// Walk the component tree and collect CSS classes
226
+ #[wasm_bindgen]
227
+ pub fn extract_css_classes(tree_json: &str) -> String {
228
+ let tree: TreeNode = match serde_json::from_str(tree_json) {
229
+ Ok(t) => t,
230
+ Err(_) => return "[]".to_string(),
231
+ };
232
+
233
+ let mut classes = HashSet::new();
234
+ walk_tree_for_classes(&tree, &mut classes);
235
+
236
+ let class_vec: Vec<&String> = classes.iter().collect();
237
+ serde_json::to_string(&class_vec).unwrap_or_else(|_| "[]".to_string())
238
+ }
239
+
240
+ fn walk_tree_for_classes(node: &TreeNode, classes: &mut HashSet<String>) {
241
+ // Extract className/class from props
242
+ if let Some(props) = &node.props {
243
+ if let Some(class_val) = props.get("className").or_else(|| props.get("class")) {
244
+ if let Some(class_str) = class_val.as_str() {
245
+ for class in class_str.split_whitespace() {
246
+ if !class.is_empty() {
247
+ classes.insert(class.to_string());
248
+ }
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ // Recurse into children
255
+ if let Some(children) = &node.children {
256
+ for child in children {
257
+ if let TreeChild::Node(node) = child {
258
+ walk_tree_for_classes(node, classes);
259
+ }
260
+ }
261
+ }
262
+ }
263
+
264
+ /// Resolve asset references in the tree (replace paths with data URIs)
265
+ #[wasm_bindgen]
266
+ pub fn resolve_assets(tree_json: &str, manifest_json: &str) -> String {
267
+ let mut tree: serde_json::Value = match serde_json::from_str(tree_json) {
268
+ Ok(t) => t,
269
+ Err(_) => return tree_json.to_string(),
270
+ };
271
+
272
+ let manifest: AssetManifest = match serde_json::from_str(manifest_json) {
273
+ Ok(m) => m,
274
+ Err(_) => return tree_json.to_string(),
275
+ };
276
+
277
+ resolve_assets_in_value(&mut tree, &manifest);
278
+ serde_json::to_string(&tree).unwrap_or_else(|_| tree_json.to_string())
279
+ }
280
+
281
+ fn resolve_assets_in_value(value: &mut serde_json::Value, manifest: &AssetManifest) {
282
+ match value {
283
+ serde_json::Value::Object(map) => {
284
+ // Check for src attribute with asset path
285
+ if let Some(src) = map.get("src").and_then(|v| v.as_str()) {
286
+ if src.starts_with('/') || src.starts_with("./") {
287
+ if let Some(data_uri) = manifest.get_data_uri(src) {
288
+ map.insert("src".to_string(), serde_json::Value::String(data_uri));
289
+ }
290
+ }
291
+ }
292
+
293
+ // Recurse into all values
294
+ for (_, v) in map.iter_mut() {
295
+ resolve_assets_in_value(v, manifest);
296
+ }
297
+ }
298
+ serde_json::Value::Array(arr) => {
299
+ for item in arr.iter_mut() {
300
+ resolve_assets_in_value(item, manifest);
301
+ }
302
+ }
303
+ _ => {}
304
+ }
305
+ }
306
+
307
+ /// HTML escape utility
308
+ fn escape_html(s: &str) -> String {
309
+ s.replace('&', "&amp;")
310
+ .replace('<', "&lt;")
311
+ .replace('>', "&gt;")
312
+ .replace('"', "&quot;")
313
+ .replace('\'', "&#039;")
314
+ }
315
+
316
+ /// Convert camelCase to kebab-case for CSS properties
317
+ fn to_kebab_case(s: &str) -> String {
318
+ let mut result = String::new();
319
+ for (i, c) in s.chars().enumerate() {
320
+ if c.is_uppercase() {
321
+ if i > 0 {
322
+ result.push('-');
323
+ }
324
+ result.push(c.to_ascii_lowercase());
325
+ } else {
326
+ result.push(c);
327
+ }
328
+ }
329
+ result
330
+ }
331
+
332
+ /// Render a component tree to HTML string
333
+ #[wasm_bindgen]
334
+ pub fn render_tree_to_html(tree_json: &str) -> String {
335
+ let tree: TreeNode = match serde_json::from_str(tree_json) {
336
+ Ok(t) => t,
337
+ Err(_) => return String::new(),
338
+ };
339
+
340
+ render_node(&tree)
341
+ }
342
+
343
+ fn render_node(node: &TreeNode) -> String {
344
+ // Known HTML elements
345
+ let html_tags: HashSet<&str> = [
346
+ "div", "span", "p", "h1", "h2", "h3", "h4", "h5", "h6",
347
+ "a", "button", "img", "svg", "section", "header", "footer",
348
+ "main", "nav", "aside", "article", "ul", "ol", "li",
349
+ "form", "input", "textarea", "select", "option", "label",
350
+ "table", "thead", "tbody", "tr", "th", "td",
351
+ "video", "audio", "source", "canvas", "iframe",
352
+ "strong", "em", "code", "pre", "blockquote", "hr", "br",
353
+ "figure", "figcaption", "picture", "time", "mark",
354
+ ].iter().cloned().collect();
355
+
356
+ let void_elements: HashSet<&str> = [
357
+ "img", "input", "br", "hr", "meta", "link", "source",
358
+ "area", "base", "col", "embed", "param", "track", "wbr",
359
+ ].iter().cloned().collect();
360
+
361
+ let node_type = &node.node_type;
362
+ let is_html = html_tags.contains(node_type.as_str());
363
+ let is_void = void_elements.contains(node_type.as_str());
364
+
365
+ // Build attributes
366
+ let mut attrs = Vec::new();
367
+ if let Some(props) = &node.props {
368
+ for (key, value) in props {
369
+ if key == "children" {
370
+ continue;
371
+ }
372
+
373
+ match key.as_str() {
374
+ "className" => {
375
+ if let Some(s) = value.as_str() {
376
+ attrs.push(format!("class=\"{}\"", escape_html(s)));
377
+ }
378
+ }
379
+ "htmlFor" => {
380
+ if let Some(s) = value.as_str() {
381
+ attrs.push(format!("for=\"{}\"", escape_html(s)));
382
+ }
383
+ }
384
+ "style" => {
385
+ if let Some(obj) = value.as_object() {
386
+ let style_parts: Vec<String> = obj.iter()
387
+ .filter_map(|(k, v)| {
388
+ v.as_str().map(|s| format!("{}: {}", to_kebab_case(k), s))
389
+ })
390
+ .collect();
391
+ if !style_parts.is_empty() {
392
+ attrs.push(format!("style=\"{}\"", escape_html(&style_parts.join("; "))));
393
+ }
394
+ }
395
+ }
396
+ _ if key.starts_with("data-") || key.starts_with("aria-") => {
397
+ if let Some(s) = value.as_str() {
398
+ attrs.push(format!("{}=\"{}\"", key, escape_html(s)));
399
+ } else if let Some(n) = value.as_f64() {
400
+ attrs.push(format!("{}=\"{}\"", key, n));
401
+ } else if let Some(b) = value.as_bool() {
402
+ if b {
403
+ attrs.push(key.clone());
404
+ }
405
+ }
406
+ }
407
+ _ => {
408
+ if let Some(s) = value.as_str() {
409
+ attrs.push(format!("{}=\"{}\"", key, escape_html(s)));
410
+ } else if let Some(n) = value.as_f64() {
411
+ attrs.push(format!("{}=\"{}\"", key, n));
412
+ } else if let Some(b) = value.as_bool() {
413
+ if b {
414
+ attrs.push(key.clone());
415
+ }
416
+ }
417
+ }
418
+ }
419
+ }
420
+ }
421
+
422
+ let attr_str = if attrs.is_empty() {
423
+ String::new()
424
+ } else {
425
+ format!(" {}", attrs.join(" "))
426
+ };
427
+
428
+ // Render children
429
+ let children_html: String = if let Some(children) = &node.children {
430
+ children.iter().map(|child| {
431
+ match child {
432
+ TreeChild::Text(text) => escape_html(text),
433
+ TreeChild::Node(node) => render_node(node),
434
+ }
435
+ }).collect()
436
+ } else {
437
+ String::new()
438
+ };
439
+
440
+ if is_html {
441
+ if is_void {
442
+ format!("<{}{}>", node_type, attr_str)
443
+ } else {
444
+ format!("<{}{}>{}</{}>", node_type, attr_str, children_html, node_type)
445
+ }
446
+ } else {
447
+ // Custom component - just render children
448
+ children_html
449
+ }
450
+ }
451
+
452
+ /// Generate CSS for collected classes
453
+ #[wasm_bindgen]
454
+ pub fn generate_css_for_classes(classes_json: &str, manifest_json: &str) -> String {
455
+ let classes: Vec<String> = match serde_json::from_str(classes_json) {
456
+ Ok(c) => c,
457
+ Err(_) => return String::new(),
458
+ };
459
+
460
+ let manifest: CSSManifest = match serde_json::from_str(manifest_json) {
461
+ Ok(m) => m,
462
+ Err(_) => return String::new(),
463
+ };
464
+
465
+ let mut css = String::new();
466
+ let mut media_rules: HashMap<String, Vec<String>> = HashMap::new();
467
+
468
+ for class in &classes {
469
+ if let Some(rules) = manifest.rules.get(class) {
470
+ for rule in rules {
471
+ let rule_css = format!("{} {{ {} }}\n", rule.selector, rule.declarations);
472
+ if let Some(media) = &rule.media_query {
473
+ media_rules
474
+ .entry(media.clone())
475
+ .or_insert_with(Vec::new)
476
+ .push(rule_css);
477
+ } else {
478
+ css.push_str(&rule_css);
479
+ }
480
+ }
481
+ }
482
+ }
483
+
484
+ // Add media query grouped rules
485
+ for (media, rules) in &media_rules {
486
+ css.push_str(&format!("@media {} {{\n", media));
487
+ for rule in rules {
488
+ css.push_str(&format!(" {}", rule));
489
+ }
490
+ css.push_str("}\n");
491
+ }
492
+
493
+ css
494
+ }
495
+
496
+ /// Generate minimal hydration script for interactive components
497
+ #[wasm_bindgen]
498
+ pub fn generate_hydration_script(interactive_nodes_json: &str, env_json: &str) -> String {
499
+ let nodes: Vec<InteractiveNode> = match serde_json::from_str(interactive_nodes_json) {
500
+ Ok(n) => n,
501
+ Err(_) => Vec::new(),
502
+ };
503
+
504
+ if nodes.is_empty() {
505
+ return String::new();
506
+ }
507
+
508
+ let component_types: Vec<&str> = nodes.iter().map(|n| n.component_type.as_str()).collect();
509
+ let components_json = serde_json::to_string(&component_types).unwrap_or_else(|_| "[]".to_string());
510
+
511
+ // Build script without format! to avoid escaping issues
512
+ let mut script = String::from("<script type=\"module\">\n");
513
+ script.push_str("// Aeon Hydration - Lazy load interactive components\n");
514
+ script.push_str("const h=async(e)=>{const c=e.dataset.aeonComponent;try{const m=await import('/_aeon/c/'+c+'.js');m.hydrate(e)}catch(err){console.error('[aeon] Failed to hydrate:',c,err)}};\n");
515
+ script.push_str("const io=new IntersectionObserver((es)=>{es.forEach(e=>{if(e.isIntersecting){io.unobserve(e.target);h(e.target)}})},{rootMargin:'100px'});\n");
516
+ script.push_str("document.querySelectorAll('[data-aeon-interactive]').forEach(e=>io.observe(e));\n");
517
+ script.push_str("window.__AEON__={env:");
518
+ script.push_str(env_json);
519
+ script.push_str(",components:");
520
+ script.push_str(&components_json);
521
+ script.push_str("};\n");
522
+ script.push_str("</script>");
523
+ script
524
+ }
525
+
526
+ /// Full page render: combines tree rendering with CSS, assets, and fonts
527
+ #[wasm_bindgen]
528
+ pub fn render_page(
529
+ tree_json: &str,
530
+ css_manifest_json: &str,
531
+ asset_manifest_json: &str,
532
+ font_manifest_json: &str,
533
+ title: &str,
534
+ description: &str,
535
+ ) -> String {
536
+ // 1. Extract CSS classes
537
+ let classes_json = extract_css_classes(tree_json);
538
+
539
+ // 2. Resolve assets
540
+ let resolved_tree = resolve_assets(tree_json, asset_manifest_json);
541
+
542
+ // 3. Render HTML
543
+ let html_content = render_tree_to_html(&resolved_tree);
544
+
545
+ // 4. Generate CSS
546
+ let component_css = generate_css_for_classes(&classes_json, css_manifest_json);
547
+
548
+ // 5. Get critical CSS and font CSS
549
+ let css_manifest: CSSManifest = serde_json::from_str(css_manifest_json)
550
+ .unwrap_or_else(|_| CSSManifest::new(String::new()));
551
+ let font_manifest: FontManifest = serde_json::from_str(font_manifest_json)
552
+ .unwrap_or_else(|_| FontManifest::new());
553
+
554
+ let critical_css = css_manifest.critical();
555
+ let font_css = font_manifest.font_face_css();
556
+
557
+ // 6. Combine all CSS
558
+ let full_css = format!("{}\n{}\n{}", critical_css, font_css, component_css);
559
+
560
+ // 7. Build full HTML document
561
+ let title_escaped = escape_html(title);
562
+ let desc_meta = if description.is_empty() {
563
+ String::new()
564
+ } else {
565
+ format!("\n <meta name=\"description\" content=\"{}\">", escape_html(description))
566
+ };
567
+
568
+ format!(r#"<!DOCTYPE html>
569
+ <html lang="en">
570
+ <head>
571
+ <meta charset="UTF-8">
572
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
573
+ <title>{}</title>{}
574
+ <style>{}</style>
575
+ </head>
576
+ <body>
577
+ <div id="root">{}</div>
578
+ </body>
579
+ </html>"#, title_escaped, desc_meta, full_css, html_content)
580
+ }
581
+
582
+ #[cfg(test)]
583
+ mod tests {
584
+ use super::*;
585
+
586
+ #[test]
587
+ fn test_extract_classes() {
588
+ let tree = r#"{
589
+ "type": "div",
590
+ "props": {"className": "flex items-center p-4"},
591
+ "children": []
592
+ }"#;
593
+
594
+ let result = extract_css_classes(tree);
595
+ let classes: Vec<String> = serde_json::from_str(&result).unwrap();
596
+
597
+ assert!(classes.contains(&"flex".to_string()));
598
+ assert!(classes.contains(&"items-center".to_string()));
599
+ assert!(classes.contains(&"p-4".to_string()));
600
+ }
601
+
602
+ #[test]
603
+ fn test_render_simple() {
604
+ let tree = r#"{
605
+ "type": "div",
606
+ "props": {"className": "container"},
607
+ "children": [{"type": "Text", "props": {}, "children": []}]
608
+ }"#;
609
+
610
+ let html = render_tree_to_html(tree);
611
+ assert!(html.contains("<div"));
612
+ assert!(html.contains("class=\"container\""));
613
+ assert!(html.contains("</div>"));
614
+ }
615
+
616
+ #[test]
617
+ fn test_escape_html() {
618
+ assert_eq!(escape_html("<script>"), "&lt;script&gt;");
619
+ assert_eq!(escape_html("a & b"), "a &amp; b");
620
+ assert_eq!(escape_html("\"test\""), "&quot;test&quot;");
621
+ }
622
+
623
+ #[test]
624
+ fn test_to_kebab_case() {
625
+ assert_eq!(to_kebab_case("backgroundColor"), "background-color");
626
+ assert_eq!(to_kebab_case("fontSize"), "font-size");
627
+ assert_eq!(to_kebab_case("color"), "color");
628
+ }
629
+ }