**Convention: RitzMain() entry point**
Every Ritz file now requires a `@component def RitzMain()` as the entry point.
This provides a consistent, predictable entry point for all applications.
**Universal Main.wasm output**
Build system now always outputs Main.wasm instead of ComponentName.wasm.
This means every example uses the exact same HTML boilerplate!
**Changes:**
**Codegen (llvm_codegen_signals.py):**
- Check for RitzMain component at generation time
- Error if RitzMain not found: "No RitzMain component found!"
- Always call `render_RitzMain()` from main() entry point
- Fix: Scope reactive text nodes to components (`@reactive_text_ComponentName_N_node`)
- Prevents global name collisions when multiple components have reactive text
**Build (build.py):**
- Output Main.wasm in same directory as source file
- Output Main.ll (LLVM IR) for inspection
- Convention over configuration!
**Loader (ritz-runtime.js):**
- Auto-detect component name from WASM filename
- `new RitzLoader().load('Main.wasm')` - no second parameter needed!
- Component name used only for status messages
**Template (examples/template.html):**
- Minimal boilerplate - literally 25 lines
- Just copy and change nothing!
- Loads Main.wasm - works for every example
**Styles (examples/runtime/styles.css):**
- Shared styles for all examples
- Clean, modern defaults
- App container, buttons, inputs, typography
**Example (HighFive):**
```python
@component
def HighFive():
# ... component logic
@component
def RitzMain():
"""Entry point - required!"""
yield HighFive()
```
**Result:**
✅ Every example: copy template.html, change nothing
✅ Every example: builds to Main.wasm
✅ Consistent entry point: RitzMain()
✅ No more component name boilerplate
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
381 lines
13 KiB
JavaScript
381 lines
13 KiB
JavaScript
/**
|
|
* Ritz Runtime - WebAssembly DOM Bridge
|
|
*
|
|
* Provides the runtime environment for Ritz components compiled to WASM.
|
|
*/
|
|
|
|
class RitzRuntime {
|
|
constructor(rootElement) {
|
|
this.rootElement = rootElement || document.getElementById('app');
|
|
this.nodes = new Map();
|
|
this.nextNodeId = 1;
|
|
this.log = [];
|
|
this.wasmInstance = null;
|
|
this.debugEnabled = true;
|
|
}
|
|
|
|
// ===== DOM Manipulation Bridge =====
|
|
|
|
js_create_element(tagPtr) {
|
|
const tag = this.readString(tagPtr);
|
|
const element = document.createElement(tag);
|
|
const id = this.nextNodeId++;
|
|
this.nodes.set(id, element);
|
|
this._log(`createElement("${tag}") = ${id}`);
|
|
return id;
|
|
}
|
|
|
|
js_create_text(textPtr) {
|
|
const text = this.readString(textPtr);
|
|
const textNode = document.createTextNode(text);
|
|
const id = this.nextNodeId++;
|
|
this.nodes.set(id, textNode);
|
|
this._log(`createTextNode("${text}") = ${id}`);
|
|
return id;
|
|
}
|
|
|
|
js_append_child(parentId, childId) {
|
|
const parent = this.nodes.get(parentId);
|
|
const child = this.nodes.get(childId);
|
|
if (parent && child) {
|
|
parent.appendChild(child);
|
|
this._log(`appendChild(${parentId}, ${childId})`);
|
|
}
|
|
}
|
|
|
|
js_set_attribute(elemId, namePtr, valuePtr) {
|
|
const element = this.nodes.get(elemId);
|
|
const name = this.readString(namePtr);
|
|
const value = this.readString(valuePtr);
|
|
if (element) {
|
|
if (name === 'class') {
|
|
element.className = value;
|
|
} else {
|
|
element.setAttribute(name, value);
|
|
}
|
|
this._log(`setAttribute(${elemId}, "${name}", "${value}")`);
|
|
}
|
|
}
|
|
|
|
js_bind_event(elemId, eventNamePtr, handlerNamePtr) {
|
|
const element = this.nodes.get(elemId);
|
|
const eventName = this.readString(eventNamePtr);
|
|
const handlerName = this.readString(handlerNamePtr);
|
|
|
|
if (element && this.wasmInstance) {
|
|
const handler = this.wasmInstance.exports[handlerName];
|
|
if (handler) {
|
|
element.addEventListener(eventName, () => {
|
|
this._log(`[Event] ${eventName} → ${handlerName}()`);
|
|
handler();
|
|
this.updateSignals();
|
|
});
|
|
this._log(`bindEvent(${elemId}, "${eventName}", "${handlerName}")`);
|
|
} else {
|
|
console.error(`Handler ${handlerName} not found in WASM exports`);
|
|
}
|
|
}
|
|
}
|
|
|
|
js_set_root(elemId) {
|
|
const element = this.nodes.get(elemId);
|
|
if (element) {
|
|
this.rootElement.innerHTML = '';
|
|
this.rootElement.appendChild(element);
|
|
this._log(`setRoot(${elemId})`);
|
|
}
|
|
}
|
|
|
|
js_update_text_content(nodeId, textPtr) {
|
|
const node = this.nodes.get(nodeId);
|
|
const text = this.readString(textPtr);
|
|
if (node) {
|
|
node.textContent = text;
|
|
this._log(`updateTextContent(${nodeId}, "${text}")`);
|
|
}
|
|
}
|
|
|
|
js_create_text_with_int(prefixPtr, intValue) {
|
|
const prefix = this.readString(prefixPtr);
|
|
const text = prefix + intValue.toString();
|
|
const textNode = document.createTextNode(text);
|
|
const id = this.nextNodeId++;
|
|
this.nodes.set(id, textNode);
|
|
this._log(`createTextWithInt("${prefix}", ${intValue}) = "${text}" (id=${id})`);
|
|
return id;
|
|
}
|
|
|
|
js_update_text_with_int(nodeId, prefixPtr, intValue) {
|
|
const node = this.nodes.get(nodeId);
|
|
const prefix = this.readString(prefixPtr);
|
|
const text = prefix + intValue.toString();
|
|
if (node) {
|
|
node.textContent = text;
|
|
this._log(`updateTextWithInt(${nodeId}, "${prefix}", ${intValue}) = "${text}"`);
|
|
}
|
|
}
|
|
|
|
// ===== Signal Management =====
|
|
|
|
updateSignals() {
|
|
// Discover and update all signals
|
|
if (!this.wasmInstance) return;
|
|
|
|
const exports = this.wasmInstance.exports;
|
|
|
|
// Find all get_* functions (signal getters)
|
|
for (const [name, fn] of Object.entries(exports)) {
|
|
if (name.startsWith('get_') && typeof fn === 'function') {
|
|
const signalName = name.substring(4); // Remove 'get_' prefix
|
|
const value = fn();
|
|
this._log(`[Signal] ${signalName} = ${value}`);
|
|
|
|
// Update DOM elements bound to this signal
|
|
// TODO: Track signal-to-DOM bindings during render for efficiency
|
|
this.updateSignalDisplay(signalName, value);
|
|
}
|
|
}
|
|
|
|
// Re-evaluate all reactive conditionals
|
|
for (const [name, fn] of Object.entries(exports)) {
|
|
if (name.startsWith('reevaluate_conditional_') && typeof fn === 'function') {
|
|
fn();
|
|
this._log(`[Reactive] ${name}()`);
|
|
}
|
|
}
|
|
|
|
// Re-evaluate all reactive match statements
|
|
for (const [name, fn] of Object.entries(exports)) {
|
|
if (name.startsWith('reevaluate_match_') && typeof fn === 'function') {
|
|
fn();
|
|
this._log(`[Reactive] ${name}()`);
|
|
}
|
|
}
|
|
}
|
|
|
|
updateSignalDisplay(signalName, value) {
|
|
// Find DOM elements that should display this signal
|
|
// For now, update any element with class matching signal name or data-signal attribute
|
|
const selectors = [
|
|
`[data-signal="${signalName}"]`,
|
|
`.${signalName}`,
|
|
'.count', // Hardcoded for demo - TODO: track bindings properly
|
|
];
|
|
|
|
selectors.forEach(selector => {
|
|
const elements = document.querySelectorAll(selector);
|
|
elements.forEach(el => {
|
|
// Check if text contains the signal name or looks like a counter
|
|
const text = el.textContent;
|
|
if (text.includes(signalName) || text.match(/\d+/)) {
|
|
// Replace the number in the text
|
|
el.textContent = text.replace(/\d+/, value);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ===== Reactive Conditional Rendering =====
|
|
|
|
js_create_marker() {
|
|
// Create a comment node as a marker for conditional insertion
|
|
const marker = document.createComment('conditional-marker');
|
|
const id = this.nextNodeId++;
|
|
this.nodes.set(id, marker);
|
|
this._log(`Created marker node ${id}`);
|
|
return id;
|
|
}
|
|
|
|
js_replace_at_marker(markerId, oldNodeId, newNodeId) {
|
|
// Replace DOM node at marker position
|
|
const marker = this.nodes.get(markerId);
|
|
if (!marker) {
|
|
console.error(`[replace_at_marker] Marker ${markerId} not found`);
|
|
return;
|
|
}
|
|
|
|
const parent = marker.parentNode;
|
|
if (!parent) {
|
|
console.error(`[replace_at_marker] Marker ${markerId} has no parent`);
|
|
return;
|
|
}
|
|
|
|
this._log(`[replace_at_marker] marker=${markerId}, old=${oldNodeId}, new=${newNodeId}`);
|
|
|
|
// Remove old node if it exists (0 means no old node to remove)
|
|
if (oldNodeId !== 0) {
|
|
const oldNode = this.nodes.get(oldNodeId);
|
|
if (!oldNode) {
|
|
console.warn(`[replace_at_marker] Old node ${oldNodeId} not found in registry`);
|
|
} else if (!oldNode.parentNode) {
|
|
console.warn(`[replace_at_marker] Old node ${oldNodeId} not in DOM`);
|
|
} else if (oldNode.parentNode !== parent) {
|
|
console.warn(`[replace_at_marker] Old node ${oldNodeId} in wrong parent`);
|
|
oldNode.parentNode.removeChild(oldNode);
|
|
this._log(`[replace_at_marker] Removed ${oldNodeId} from wrong parent`);
|
|
} else {
|
|
// Remove from correct parent
|
|
parent.removeChild(oldNode);
|
|
this._log(`[replace_at_marker] Removed ${oldNodeId} from correct parent`);
|
|
}
|
|
}
|
|
|
|
// Insert new node after marker (only if newNodeId is not 0)
|
|
if (newNodeId !== 0) {
|
|
const newNode = this.nodes.get(newNodeId);
|
|
if (!newNode) {
|
|
console.error(`[replace_at_marker] New node ${newNodeId} not found`);
|
|
return;
|
|
}
|
|
|
|
// Insert right after the marker
|
|
// If newNode is already in DOM elsewhere, this will move it
|
|
const nextSibling = marker.nextSibling;
|
|
if (nextSibling) {
|
|
parent.insertBefore(newNode, nextSibling);
|
|
this._log(`[replace_at_marker] Inserted ${newNodeId} before sibling`);
|
|
} else {
|
|
parent.appendChild(newNode);
|
|
this._log(`[replace_at_marker] Appended ${newNodeId} (no sibling)`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Memory & String Utilities =====
|
|
|
|
readString(ptr) {
|
|
if (!this.memory) return '';
|
|
const bytes = new Uint8Array(this.memory.buffer, ptr);
|
|
let length = 0;
|
|
while (bytes[length] !== 0) length++;
|
|
return new TextDecoder().decode(bytes.slice(0, length));
|
|
}
|
|
|
|
setMemory(memory) {
|
|
this.memory = memory;
|
|
}
|
|
|
|
setWasmInstance(instance) {
|
|
this.wasmInstance = instance;
|
|
}
|
|
|
|
// ===== Logging & Debug =====
|
|
|
|
_log(message) {
|
|
if (this.debugEnabled) {
|
|
this.log.push(message);
|
|
}
|
|
}
|
|
|
|
getLog() {
|
|
return this.log;
|
|
}
|
|
|
|
clearLog() {
|
|
this.log = [];
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* RitzLoader - Simplified WASM loading
|
|
*/
|
|
class RitzLoader {
|
|
constructor(options = {}) {
|
|
this.rootElement = options.rootElement || document.getElementById('app');
|
|
this.statusElement = options.statusElement || document.getElementById('status');
|
|
this.onLoad = options.onLoad || (() => {});
|
|
this.onError = options.onError || ((err) => console.error(err));
|
|
}
|
|
|
|
async load(wasmPath, componentName = null, props = {}) {
|
|
try {
|
|
// Auto-detect component name from wasm filename if not provided
|
|
if (!componentName) {
|
|
// Extract from path: "Main.wasm" → "Main", "path/to/Main.wasm" → "Main"
|
|
componentName = wasmPath.split('/').pop().replace('.wasm', '');
|
|
}
|
|
|
|
this.setStatus('⏳ Creating runtime...');
|
|
const runtime = new RitzRuntime(this.rootElement);
|
|
|
|
this.setStatus(`⏳ Loading ${wasmPath}...`);
|
|
const response = await fetch(wasmPath);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load ${wasmPath}: ${response.status}`);
|
|
}
|
|
|
|
const wasmBytes = await response.arrayBuffer();
|
|
|
|
this.setStatus('⏳ Instantiating WASM...');
|
|
|
|
// Create imports for WASM
|
|
const imports = {
|
|
env: {
|
|
js_create_element: (ptr) => runtime.js_create_element(ptr),
|
|
js_create_text: (ptr) => runtime.js_create_text(ptr),
|
|
js_create_text_with_int: (p, i) => runtime.js_create_text_with_int(p, i),
|
|
js_append_child: (p, c) => runtime.js_append_child(p, c),
|
|
js_set_attribute: (e, n, v) => runtime.js_set_attribute(e, n, v),
|
|
js_set_root: (e) => runtime.js_set_root(e),
|
|
js_bind_event: (e, n, h) => runtime.js_bind_event(e, n, h),
|
|
js_update_text_content: (n, t) => runtime.js_update_text_content(n, t),
|
|
js_update_text_with_int: (n, p, i) => runtime.js_update_text_with_int(n, p, i),
|
|
js_create_marker: () => runtime.js_create_marker(),
|
|
js_replace_at_marker: (m, o, n) => runtime.js_replace_at_marker(m, o, n),
|
|
}
|
|
};
|
|
|
|
const { instance } = await WebAssembly.instantiate(wasmBytes, imports);
|
|
|
|
// Setup runtime
|
|
runtime.setMemory(instance.exports.memory);
|
|
runtime.setWasmInstance(instance);
|
|
|
|
this.setStatus(`⏳ Rendering ${componentName}...`);
|
|
|
|
// Call the main entry point (which renders top-level component and sets root)
|
|
const mainFn = instance.exports.main;
|
|
if (!mainFn) {
|
|
throw new Error(`main function not found in WASM exports`);
|
|
}
|
|
|
|
mainFn();
|
|
|
|
// Update signals
|
|
runtime.updateSignals();
|
|
|
|
this.setStatus(`✅ ${componentName} loaded!`, 'success');
|
|
|
|
// Make runtime globally available for debugging
|
|
window.ritzRuntime = runtime;
|
|
window.ritzInstance = instance;
|
|
|
|
this.onLoad(runtime, instance);
|
|
|
|
return { runtime, instance };
|
|
|
|
} catch (error) {
|
|
this.setStatus(`❌ Error: ${error.message}`, 'error');
|
|
this.onError(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
setStatus(message, type = '') {
|
|
if (this.statusElement) {
|
|
this.statusElement.textContent = message;
|
|
this.statusElement.className = 'status ' + type;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// Export for use in modules or global scope
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = { RitzRuntime, RitzLoader };
|
|
} else {
|
|
window.RitzRuntime = RitzRuntime;
|
|
window.RitzLoader = RitzLoader;
|
|
}
|