feat: add stdlib and browser examples
- stdlib/html.lux: Type-safe HTML construction - stdlib/browser.lux: Browser utilities - examples/web/: Counter app with DOM manipulation - examples/counter.lux: Simple counter example Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
154
examples/web/README.md
Normal file
154
examples/web/README.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Lux Web Examples
|
||||
|
||||
Interactive browser examples demonstrating Lux compiled to JavaScript.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Compile the counter example
|
||||
lux compile examples/web/counter.lux --target js -o examples/web/counter.js
|
||||
|
||||
# 2. Start a local server
|
||||
cd examples/web
|
||||
./serve.sh
|
||||
|
||||
# 3. Open in browser
|
||||
# http://localhost:8080/index.html
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Counter (String-based)
|
||||
|
||||
A simple counter demonstrating the TEA (The Elm Architecture) pattern using string concatenation for HTML:
|
||||
|
||||
```lux
|
||||
fn view(model: Model): String = {
|
||||
"<div>" + toString(getCount(model)) + "</div>"
|
||||
}
|
||||
```
|
||||
|
||||
### Counter with Html Module (Type-safe)
|
||||
|
||||
The same counter using the type-safe Html module:
|
||||
|
||||
```lux
|
||||
fn view(model: Model): String = {
|
||||
let count = getCount(model)
|
||||
let display = Html.div([Html.class("display")], [Html.text(toString(count))])
|
||||
Html.render(display)
|
||||
}
|
||||
```
|
||||
|
||||
Compile with:
|
||||
```bash
|
||||
lux compile examples/web/counter_html.lux --target js -o examples/web/counter_html.js
|
||||
```
|
||||
|
||||
## The Elm Architecture (TEA)
|
||||
|
||||
Both examples follow the TEA pattern:
|
||||
|
||||
- **Model**: Application state (`Counter(Int)`)
|
||||
- **Msg**: Events (`Increment`, `Decrement`, `Reset`)
|
||||
- **Update**: State transitions `(Model, Msg) -> Model`
|
||||
- **View**: HTML rendering `Model -> Html` or `Model -> String`
|
||||
|
||||
## Available Modules
|
||||
|
||||
### Html Module
|
||||
|
||||
Type-safe HTML construction:
|
||||
|
||||
```lux
|
||||
// Elements
|
||||
Html.div(attrs, children)
|
||||
Html.span(attrs, children)
|
||||
Html.button(attrs, children)
|
||||
Html.input(attrs, children)
|
||||
Html.h1(attrs, children)
|
||||
// ... 30+ elements
|
||||
|
||||
// Attributes
|
||||
Html.class("name")
|
||||
Html.id("id")
|
||||
Html.href("url")
|
||||
Html.style("...")
|
||||
Html.attr("name", "value")
|
||||
|
||||
// Events
|
||||
Html.onClick(handler)
|
||||
Html.onInput(handler)
|
||||
Html.on("event", handler)
|
||||
|
||||
// Text
|
||||
Html.text("content")
|
||||
|
||||
// Rendering
|
||||
Html.render(node) // -> String (for innerHTML)
|
||||
Html.renderToDom(node) // -> Element (for appendChild)
|
||||
```
|
||||
|
||||
### Dom Effect
|
||||
|
||||
Direct DOM manipulation:
|
||||
|
||||
```lux
|
||||
fn main(): Unit with {Dom} = {
|
||||
match Dom.querySelector("#app") {
|
||||
Some(el) => Dom.setTextContent(el, "Hello!"),
|
||||
None => ()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Available operations:
|
||||
- `querySelector`, `querySelectorAll`, `getElementById`
|
||||
- `createElement`, `createTextNode`, `appendChild`
|
||||
- `setTextContent`, `getTextContent`, `setInnerHtml`
|
||||
- `setAttribute`, `getAttribute`, `addClass`, `removeClass`
|
||||
- `addEventListener`, `focus`, `blur`
|
||||
- `getValue`, `setValue` (for inputs)
|
||||
- `scrollTo`, `getWindowSize`, `getBoundingClientRect`
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Lux code is compiled to JavaScript using `lux compile --target js`
|
||||
2. The HTML page loads the compiled JS
|
||||
3. A minimal runtime handles the TEA loop:
|
||||
- `luxInit_lux()` creates initial state
|
||||
- `luxUpdate_lux(model, msg)` handles state updates
|
||||
- `luxView_lux(model)` renders HTML
|
||||
- `dispatch(msg)` triggers updates and re-renders
|
||||
|
||||
## TEA Runtime
|
||||
|
||||
For applications using the Html module with event handlers, you can use the built-in TEA runtime:
|
||||
|
||||
```javascript
|
||||
Lux.app({
|
||||
init: luxInit_lux(),
|
||||
update: (model, msg) => luxUpdate_lux(model, msg),
|
||||
view: (model, dispatch) => luxView_lux(model),
|
||||
root: "#app"
|
||||
});
|
||||
```
|
||||
|
||||
Or for string-based views:
|
||||
|
||||
```javascript
|
||||
Lux.simpleApp({
|
||||
init: luxInit_lux(),
|
||||
update: (model, msg) => luxUpdate_lux(model, msg),
|
||||
view: (model) => luxView_lux(model),
|
||||
root: "#app"
|
||||
});
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
To modify an example:
|
||||
|
||||
1. Edit the `.lux` file
|
||||
2. Recompile: `lux compile <file>.lux --target js -o <file>.js`
|
||||
3. Refresh the browser
|
||||
144
examples/web/counter.js
Normal file
144
examples/web/counter.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// Lux Runtime
|
||||
const Lux = {
|
||||
Some: (value) => ({ tag: "Some", value }),
|
||||
None: () => ({ tag: "None" }),
|
||||
|
||||
Ok: (value) => ({ tag: "Ok", value }),
|
||||
Err: (error) => ({ tag: "Err", error }),
|
||||
|
||||
Cons: (head, tail) => [head, ...tail],
|
||||
Nil: () => [],
|
||||
|
||||
defaultHandlers: {
|
||||
Console: {
|
||||
print: (msg) => console.log(msg),
|
||||
readLine: () => {
|
||||
if (typeof require !== 'undefined') {
|
||||
const readline = require('readline');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise(resolve => rl.question('', answer => { rl.close(); resolve(answer); }));
|
||||
}
|
||||
return prompt('') || '';
|
||||
},
|
||||
readInt: () => parseInt(Lux.defaultHandlers.Console.readLine(), 10)
|
||||
},
|
||||
Random: {
|
||||
int: (min, max) => Math.floor(Math.random() * (max - min + 1)) + min,
|
||||
bool: () => Math.random() < 0.5,
|
||||
float: () => Math.random()
|
||||
},
|
||||
Time: {
|
||||
now: () => Date.now(),
|
||||
sleep: (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
},
|
||||
Http: {
|
||||
get: async (url) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const body = await response.text();
|
||||
const headers = [];
|
||||
response.headers.forEach((v, k) => headers.push([k, v]));
|
||||
return Lux.Ok({ status: response.status, body, headers });
|
||||
} catch (e) {
|
||||
return Lux.Err(e.message);
|
||||
}
|
||||
},
|
||||
post: async (url, body) => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'POST', body });
|
||||
const respBody = await response.text();
|
||||
const headers = [];
|
||||
response.headers.forEach((v, k) => headers.push([k, v]));
|
||||
return Lux.Ok({ status: response.status, body: respBody, headers });
|
||||
} catch (e) {
|
||||
return Lux.Err(e.message);
|
||||
}
|
||||
},
|
||||
postJson: async (url, json) => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(json)
|
||||
});
|
||||
const body = await response.text();
|
||||
const headers = [];
|
||||
response.headers.forEach((v, k) => headers.push([k, v]));
|
||||
return Lux.Ok({ status: response.status, body, headers });
|
||||
} catch (e) {
|
||||
return Lux.Err(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Model constructors
|
||||
function Counter_lux(field0) { return { tag: "Counter", field0: field0 }; }
|
||||
|
||||
// Msg constructors
|
||||
const Increment_lux = { tag: "Increment" };
|
||||
const Decrement_lux = { tag: "Decrement" };
|
||||
const Reset_lux = { tag: "Reset" };
|
||||
|
||||
function getCount_lux(m) {
|
||||
const _match_0 = m;
|
||||
let _result_1;
|
||||
if (_match_0.tag === "Counter") {
|
||||
const n = _match_0.field0;
|
||||
_result_1 = n;
|
||||
} else {
|
||||
throw new Error('Non-exhaustive match on ' + JSON.stringify(_match_0));
|
||||
}
|
||||
return _result_1;
|
||||
}
|
||||
|
||||
function init_lux() {
|
||||
return Counter_lux(0);
|
||||
}
|
||||
|
||||
function update_lux(model, msg) {
|
||||
const _match_2 = msg;
|
||||
let _result_3;
|
||||
if (_match_2.tag === "Increment") {
|
||||
_result_3 = Counter_lux((getCount_lux(model) + 1));
|
||||
} else if (_match_2.tag === "Decrement") {
|
||||
_result_3 = Counter_lux((getCount_lux(model) - 1));
|
||||
} else if (_match_2.tag === "Reset") {
|
||||
_result_3 = Counter_lux(0);
|
||||
} else {
|
||||
throw new Error('Non-exhaustive match on ' + JSON.stringify(_match_2));
|
||||
}
|
||||
return _result_3;
|
||||
}
|
||||
|
||||
function view_lux(model) {
|
||||
const count_4 = getCount_lux(model);
|
||||
return (((((((((("<div class=\"counter\">" + "<h1>Lux Counter</h1>") + "<div class=\"display\">") + String(count_4)) + "</div>") + "<div class=\"buttons\">") + "<button onclick=\"dispatch('Decrement')\">-</button>") + "<button onclick=\"dispatch('Reset')\">Reset</button>") + "<button onclick=\"dispatch('Increment')\">+</button>") + "</div>") + "</div>");
|
||||
}
|
||||
|
||||
function luxInit_lux() {
|
||||
return init_lux();
|
||||
}
|
||||
|
||||
function luxUpdate_lux(model, msgName) {
|
||||
const _match_5 = msgName;
|
||||
let _result_6;
|
||||
if (_match_5 === "Increment") {
|
||||
_result_6 = update_lux(model, Increment_lux);
|
||||
} else if (_match_5 === "Decrement") {
|
||||
_result_6 = update_lux(model, Decrement_lux);
|
||||
} else if (_match_5 === "Reset") {
|
||||
_result_6 = update_lux(model, Reset_lux);
|
||||
} else if (true) {
|
||||
_result_6 = model;
|
||||
} else {
|
||||
throw new Error('Non-exhaustive match on ' + JSON.stringify(_match_5));
|
||||
}
|
||||
return _result_6;
|
||||
}
|
||||
|
||||
function luxView_lux(model) {
|
||||
return view_lux(model);
|
||||
}
|
||||
|
||||
62
examples/web/counter.lux
Normal file
62
examples/web/counter.lux
Normal file
@@ -0,0 +1,62 @@
|
||||
// Simple Counter for Browser
|
||||
// Compile with: lux compile examples/web/counter.lux --target js -o examples/web/counter.js
|
||||
|
||||
// ============================================================================
|
||||
// Model
|
||||
// ============================================================================
|
||||
|
||||
type Model = | Counter(Int)
|
||||
|
||||
fn getCount(m: Model): Int = match m { Counter(n) => n }
|
||||
|
||||
fn init(): Model = Counter(0)
|
||||
|
||||
// ============================================================================
|
||||
// Messages
|
||||
// ============================================================================
|
||||
|
||||
type Msg = | Increment | Decrement | Reset
|
||||
|
||||
// ============================================================================
|
||||
// Update
|
||||
// ============================================================================
|
||||
|
||||
fn update(model: Model, msg: Msg): Model =
|
||||
match msg {
|
||||
Increment => Counter(getCount(model) + 1),
|
||||
Decrement => Counter(getCount(model) - 1),
|
||||
Reset => Counter(0)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// View - Returns HTML string for simplicity
|
||||
// ============================================================================
|
||||
|
||||
fn view(model: Model): String = {
|
||||
let count = getCount(model)
|
||||
"<div class=\"counter\">" +
|
||||
"<h1>Lux Counter</h1>" +
|
||||
"<div class=\"display\">" + toString(count) + "</div>" +
|
||||
"<div class=\"buttons\">" +
|
||||
"<button onclick=\"dispatch('Decrement')\">-</button>" +
|
||||
"<button onclick=\"dispatch('Reset')\">Reset</button>" +
|
||||
"<button onclick=\"dispatch('Increment')\">+</button>" +
|
||||
"</div>" +
|
||||
"</div>"
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export for browser runtime
|
||||
// ============================================================================
|
||||
|
||||
fn luxInit(): Model = init()
|
||||
|
||||
fn luxUpdate(model: Model, msgName: String): Model =
|
||||
match msgName {
|
||||
"Increment" => update(model, Increment),
|
||||
"Decrement" => update(model, Decrement),
|
||||
"Reset" => update(model, Reset),
|
||||
_ => model
|
||||
}
|
||||
|
||||
fn luxView(model: Model): String = view(model)
|
||||
82
examples/web/counter_html.lux
Normal file
82
examples/web/counter_html.lux
Normal file
@@ -0,0 +1,82 @@
|
||||
// Counter with Html Module (Type-safe HTML)
|
||||
// Compile with: lux compile examples/web/counter_html.lux --target js -o examples/web/counter_html.js
|
||||
//
|
||||
// This version uses the Html module for type-safe HTML construction
|
||||
// instead of string concatenation. The Html module provides:
|
||||
// - Type-safe element constructors (div, button, etc.)
|
||||
// - Type-safe attribute constructors (class, onClick, etc.)
|
||||
// - Automatic HTML escaping
|
||||
|
||||
// ============================================================================
|
||||
// Model
|
||||
// ============================================================================
|
||||
|
||||
type Model = | Counter(Int)
|
||||
|
||||
fn getCount(m: Model): Int = match m { Counter(n) => n }
|
||||
|
||||
fn init(): Model = Counter(0)
|
||||
|
||||
// ============================================================================
|
||||
// Messages
|
||||
// ============================================================================
|
||||
|
||||
type Msg = | Increment | Decrement | Reset
|
||||
|
||||
// ============================================================================
|
||||
// Update
|
||||
// ============================================================================
|
||||
|
||||
fn update(model: Model, msg: Msg): Model =
|
||||
match msg {
|
||||
Increment => Counter(getCount(model) + 1),
|
||||
Decrement => Counter(getCount(model) - 1),
|
||||
Reset => Counter(0)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// View - Type-safe HTML using Html module
|
||||
// ============================================================================
|
||||
|
||||
fn view(model: Model): String = {
|
||||
let count = getCount(model)
|
||||
|
||||
// Build HTML tree using Html module
|
||||
let title = Html.h1([], [Html.text("Lux Counter")])
|
||||
let display = Html.div([Html.class("display")], [Html.text(toString(count))])
|
||||
|
||||
let decBtn = Html.button(
|
||||
[Html.attr("onclick", "dispatch('Decrement')")],
|
||||
[Html.text("-")]
|
||||
)
|
||||
let resetBtn = Html.button(
|
||||
[Html.attr("onclick", "dispatch('Reset')")],
|
||||
[Html.text("Reset")]
|
||||
)
|
||||
let incBtn = Html.button(
|
||||
[Html.attr("onclick", "dispatch('Increment')")],
|
||||
[Html.text("+")]
|
||||
)
|
||||
let buttons = Html.div([Html.class("buttons")], [decBtn, resetBtn, incBtn])
|
||||
|
||||
let container = Html.div([Html.class("counter")], [title, display, buttons])
|
||||
|
||||
// Render to HTML string
|
||||
Html.render(container)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Export for browser runtime
|
||||
// ============================================================================
|
||||
|
||||
fn luxInit(): Model = init()
|
||||
|
||||
fn luxUpdate(model: Model, msgName: String): Model =
|
||||
match msgName {
|
||||
"Increment" => update(model, Increment),
|
||||
"Decrement" => update(model, Decrement),
|
||||
"Reset" => update(model, Reset),
|
||||
_ => model
|
||||
}
|
||||
|
||||
fn luxView(model: Model): String = view(model)
|
||||
133
examples/web/index.html
Normal file
133
examples/web/index.html
Normal file
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Lux Counter Example</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.counter {
|
||||
background: white;
|
||||
padding: 2rem 3rem;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.display {
|
||||
font-size: 4rem;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
padding: 1rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 1.5rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, box-shadow 0.1s;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
button:nth-child(1) {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
button:nth-child(2) {
|
||||
background: #868e96;
|
||||
color: white;
|
||||
}
|
||||
|
||||
button:nth-child(3) {
|
||||
background: #51cf66;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
color: white;
|
||||
opacity: 0.8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">Loading...</div>
|
||||
|
||||
<div class="footer">
|
||||
Built with <strong>Lux</strong> - A functional language with first-class effects
|
||||
</div>
|
||||
|
||||
<!-- Load compiled Lux code -->
|
||||
<script src="counter.js"></script>
|
||||
|
||||
<!-- Minimal TEA runtime -->
|
||||
<script>
|
||||
// Global state
|
||||
let model = luxInit_lux();
|
||||
|
||||
// Dispatch function called by button onclick handlers
|
||||
function dispatch(msgName) {
|
||||
model = luxUpdate_lux(model, msgName);
|
||||
render();
|
||||
}
|
||||
|
||||
// Render the view
|
||||
function render() {
|
||||
const html = luxView_lux(model);
|
||||
document.getElementById('app').innerHTML = html;
|
||||
}
|
||||
|
||||
// Initial render
|
||||
render();
|
||||
|
||||
console.log('Lux Counter loaded!');
|
||||
console.log('Initial model:', model);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
26
examples/web/serve.sh
Executable file
26
examples/web/serve.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Simple HTTP server for testing Lux web examples
|
||||
# Usage: ./serve.sh [port]
|
||||
|
||||
PORT=${1:-8080}
|
||||
DIR="$(dirname "$0")"
|
||||
|
||||
echo "Serving Lux web examples at http://localhost:$PORT"
|
||||
echo "Open http://localhost:$PORT/index.html in your browser"
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
cd "$DIR"
|
||||
|
||||
# Try python3 first, then python, then node
|
||||
if command -v python3 &> /dev/null; then
|
||||
python3 -m http.server $PORT
|
||||
elif command -v python &> /dev/null; then
|
||||
python -m SimpleHTTPServer $PORT
|
||||
elif command -v npx &> /dev/null; then
|
||||
npx serve -p $PORT .
|
||||
else
|
||||
echo "Error: No suitable HTTP server found."
|
||||
echo "Install Python or Node.js to serve files."
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user