@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.
- package/CHANGELOG.md +112 -0
- package/README.md +625 -0
- package/examples/basic/aeon.config.ts +39 -0
- package/examples/basic/components/Cursor.tsx +86 -0
- package/examples/basic/components/OfflineIndicator.tsx +103 -0
- package/examples/basic/components/PresenceBar.tsx +77 -0
- package/examples/basic/package.json +20 -0
- package/examples/basic/pages/index.tsx +80 -0
- package/package.json +101 -0
- package/packages/analytics/README.md +309 -0
- package/packages/analytics/build.ts +35 -0
- package/packages/analytics/package.json +50 -0
- package/packages/analytics/src/click-tracker.ts +368 -0
- package/packages/analytics/src/context-bridge.ts +319 -0
- package/packages/analytics/src/data-layer.ts +302 -0
- package/packages/analytics/src/gtm-loader.ts +239 -0
- package/packages/analytics/src/index.ts +230 -0
- package/packages/analytics/src/merkle-tree.ts +489 -0
- package/packages/analytics/src/provider.tsx +300 -0
- package/packages/analytics/src/types.ts +320 -0
- package/packages/analytics/src/use-analytics.ts +296 -0
- package/packages/analytics/tsconfig.json +19 -0
- package/packages/benchmarks/src/benchmark.test.ts +691 -0
- package/packages/cli/dist/index.js +61899 -0
- package/packages/cli/package.json +43 -0
- package/packages/cli/src/commands/build.test.ts +682 -0
- package/packages/cli/src/commands/build.ts +890 -0
- package/packages/cli/src/commands/dev.ts +473 -0
- package/packages/cli/src/commands/init.ts +409 -0
- package/packages/cli/src/commands/start.ts +297 -0
- package/packages/cli/src/index.ts +105 -0
- package/packages/directives/src/use-aeon.ts +272 -0
- package/packages/mcp-server/package.json +51 -0
- package/packages/mcp-server/src/index.ts +178 -0
- package/packages/mcp-server/src/resources.ts +346 -0
- package/packages/mcp-server/src/tools/index.ts +36 -0
- package/packages/mcp-server/src/tools/navigation.ts +545 -0
- package/packages/mcp-server/tsconfig.json +21 -0
- package/packages/react/package.json +40 -0
- package/packages/react/src/Link.tsx +388 -0
- package/packages/react/src/components/InstallPrompt.tsx +286 -0
- package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
- package/packages/react/src/components/PushNotifications.tsx +453 -0
- package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
- package/packages/react/src/hooks/useConflicts.ts +277 -0
- package/packages/react/src/hooks/useNetworkState.ts +209 -0
- package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
- package/packages/react/src/hooks/useServiceWorker.ts +278 -0
- package/packages/react/src/hooks.ts +195 -0
- package/packages/react/src/index.ts +151 -0
- package/packages/react/src/provider.tsx +467 -0
- package/packages/react/tsconfig.json +19 -0
- package/packages/runtime/README.md +399 -0
- package/packages/runtime/build.ts +48 -0
- package/packages/runtime/package.json +71 -0
- package/packages/runtime/schema.sql +40 -0
- package/packages/runtime/src/api-routes.ts +465 -0
- package/packages/runtime/src/benchmark.ts +171 -0
- package/packages/runtime/src/cache.ts +479 -0
- package/packages/runtime/src/durable-object.ts +1341 -0
- package/packages/runtime/src/index.ts +360 -0
- package/packages/runtime/src/navigation.test.ts +421 -0
- package/packages/runtime/src/navigation.ts +422 -0
- package/packages/runtime/src/nextjs-adapter.ts +272 -0
- package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
- package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
- package/packages/runtime/src/offline/encryption.test.ts +412 -0
- package/packages/runtime/src/offline/encryption.ts +397 -0
- package/packages/runtime/src/offline/types.ts +465 -0
- package/packages/runtime/src/predictor.ts +371 -0
- package/packages/runtime/src/registry.ts +351 -0
- package/packages/runtime/src/router/context-extractor.ts +661 -0
- package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
- package/packages/runtime/src/router/esi-control.ts +541 -0
- package/packages/runtime/src/router/esi-cyrano.ts +779 -0
- package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
- package/packages/runtime/src/router/esi-react.tsx +1065 -0
- package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
- package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
- package/packages/runtime/src/router/esi-translate.ts +503 -0
- package/packages/runtime/src/router/esi.ts +666 -0
- package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
- package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
- package/packages/runtime/src/router/index.ts +298 -0
- package/packages/runtime/src/router/merkle-capability.ts +473 -0
- package/packages/runtime/src/router/speculation.ts +451 -0
- package/packages/runtime/src/router/types.ts +630 -0
- package/packages/runtime/src/router.test.ts +470 -0
- package/packages/runtime/src/router.ts +302 -0
- package/packages/runtime/src/server.ts +481 -0
- package/packages/runtime/src/service-worker-push.ts +319 -0
- package/packages/runtime/src/service-worker.ts +553 -0
- package/packages/runtime/src/skeleton-hydrate.ts +237 -0
- package/packages/runtime/src/speculation.test.ts +389 -0
- package/packages/runtime/src/speculation.ts +486 -0
- package/packages/runtime/src/storage.test.ts +1297 -0
- package/packages/runtime/src/storage.ts +1048 -0
- package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
- package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
- package/packages/runtime/src/sync/coordinator.test.ts +608 -0
- package/packages/runtime/src/sync/coordinator.ts +596 -0
- package/packages/runtime/src/tree-compiler.ts +295 -0
- package/packages/runtime/src/types.ts +728 -0
- package/packages/runtime/src/worker.ts +327 -0
- package/packages/runtime/tsconfig.json +20 -0
- package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
- package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
- package/packages/runtime/wasm/package.json +21 -0
- package/packages/runtime/wrangler.toml +41 -0
- package/packages/runtime-wasm/Cargo.lock +436 -0
- package/packages/runtime-wasm/Cargo.toml +29 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
- package/packages/runtime-wasm/pkg/package.json +21 -0
- package/packages/runtime-wasm/src/hydrate.rs +352 -0
- package/packages/runtime-wasm/src/lib.rs +191 -0
- package/packages/runtime-wasm/src/render.rs +629 -0
- package/packages/runtime-wasm/src/router.rs +298 -0
- package/packages/runtime-wasm/src/skeleton.rs +430 -0
- 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('&', "&")
|
|
310
|
+
.replace('<', "<")
|
|
311
|
+
.replace('>', ">")
|
|
312
|
+
.replace('"', """)
|
|
313
|
+
.replace('\'', "'")
|
|
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>"), "<script>");
|
|
619
|
+
assert_eq!(escape_html("a & b"), "a & b");
|
|
620
|
+
assert_eq!(escape_html("\"test\""), ""test"");
|
|
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
|
+
}
|