A lightweight YAML-based web framework with reactive components, written in Perl and JavaScript.
- Declarative YAML components - Define UI with simple YAML syntax
- Reactive data binding - Two-way binding, computed properties, watchers
- Shorthand syntax -
$varfor getters/setters,@clickfor events, implicitthis - LESS compiler - Variables, nesting, mixins, color functions, math
- Component composition - Imports, slots, refs, broadcast/receive
- SPA routing - Client-side navigation with route parameters
- Transitions - Animated conditional rendering
- Developer experience - Hot reload, error overlays, HTML caching
perl webserver.plThe server runs on http://localhost:5000 by default.
Enable hot reload for automatic browser refresh on file changes:
SPIDERPUP_DEV=1 perl webserver.plspiderpup/
├── webserver.pl # Perl web server
├── spiderpup.js # Client-side JavaScript runtime
└── pages/ # YAML page definitions
├── index.yaml # Served at / or /index.html
├── foo.yaml # Served at /foo or /foo.html
└── ...
Each page is defined in a YAML file with the following fields:
title: My Page Title
css: |
button { background-color: blue; }
less: |
@primary: #333;
div {
color: @primary;
&:hover { color: red; }
}
import:
foo: /foo.yaml
bar: /bar.yaml
vars:
count: 0
name: "World"
methods:
increment: () => { $count = $count + 1 }
html: |
<h1>Hello, {name}!</h1>
<button @click="increment()">Click me</button>| Field | Description |
|---|---|
title |
Page title (appears in browser tab) |
css |
Raw CSS styles (scoped to the page) |
less |
LESS styles (compiled to CSS, scoped to the page) |
import |
Map of component namespaces to YAML file paths |
vars |
Reactive variables with initial values |
computed |
Derived values that auto-update when dependencies change |
watch |
Callbacks triggered when specific variables change |
methods |
JavaScript functions available to the page |
lifecycle |
Hooks for mount/destroy events (onMount, onDestroy) |
routes |
SPA route definitions (path → component mapping) |
html |
HTML template with special syntax |
Standard CSS in the css field is automatically scoped to the page namespace:
css: |
button { background-color: skyblue; }
.highlight { color: red; }The less field supports a subset of LESS syntax:
Define variables with @name: value; and use them anywhere:
@primary-color: #3498db;
@spacing: 10px;
div {
color: @primary-color;
padding: @spacing;
}Nest selectors for cleaner, more organized styles:
nav {
background: #333;
ul {
list-style: none;
li {
display: inline-block;
}
}
}Compiles to:
nav { background: #333; }
nav ul { list-style: none; }
nav ul li { display: inline-block; }Reference the parent selector with &:
button {
background: blue;
&:hover {
background: darkblue;
}
&.active {
background: green;
}
&-icon {
margin-right: 5px;
}
}Compiles to:
button { background: blue; }
button:hover { background: darkblue; }
button.active { background: green; }
button-icon { margin-right: 5px; }Define reusable style blocks with parameters:
.border-radius(@r) {
border-radius: @r;
}
.box-shadow(@x, @y, @blur, @color) {
box-shadow: @x @y @blur @color;
}
.card {
.border-radius(8px);
.box-shadow(0, 2px, 4px, rgba(0,0,0,0.1));
}Manipulate colors dynamically:
@primary: #3498db;
.button {
background: @primary;
&:hover {
background: darken(@primary, 10%); // Darker shade
}
&:active {
background: darken(@primary, 20%);
}
&.light {
background: lighten(@primary, 30%); // Lighter shade
}
&.blend {
background: mix(@primary, #e74c3c, 50%); // Blend two colors
}
}Available functions:
darken(@color, amount%)- Decrease lightnesslighten(@color, amount%)- Increase lightnessmix(@color1, @color2, weight%)- Blend two colors
Perform calculations with units:
@base: 10px;
@columns: 12;
.container {
padding: @base * 2; // 20px
margin: @base + 5px; // 15px
width: 100% / @columns; // 8.333...%
font-size: @base * 1.5; // 15px
}Supports +, -, *, / with automatic unit preservation.
@brand: #e74c3c;
@spacing: 10px;
.border-radius(@r) {
border-radius: @r;
}
.card {
border: 1px solid @brand;
padding: @spacing * 2;
.border-radius(8px);
h2 {
color: @brand;
}
&:hover {
border-color: darken(@brand, 15%);
}
&.featured {
background: lighten(@brand, 40%);
}
}vars:
count: 0
name: "Guest"
items: ["apple", "banana", "cherry"]Interpolate variables with {varName}:
html: |
<p>Hello, {name}!</p>
<p>Count: {count}</p>Use shorthand $var syntax or auto-generated getters/setters:
methods:
# Shorthand syntax
increment: () => { $count = $count + 1 }
reset: () => { $count = 0 }
greet: () => alert('Hello, ' + $name)
# Traditional syntax (also works)
# increment: () => this.set_count(this.get_count() + 1)Bind input values directly to variables with bind:
vars:
name: ""
agreed: false
html: |
<input bind="name" placeholder="Enter name"/>
<input type="checkbox" bind="agreed"/>
<p>Hello, {name}!</p>Changes to the input automatically update the variable, and vice versa.
Define derived values that auto-update:
vars:
firstName: "John"
lastName: "Doe"
items: []
computed:
fullName: () => `${$firstName} ${$lastName}`
itemCount: () => $items.length
isEmpty: () => $items.length === 0
html: |
<h1>Welcome, {fullName}!</h1>
<p>You have {itemCount} items</p>React to variable changes:
vars:
count: 0
searchQuery: ""
watch:
count: (newVal, oldVal) => console.log(`Count changed: ${oldVal} → ${newVal}`)
searchQuery: (newVal) => performSearch(newVal)
methods:
performSearch: (query) => console.log('Searching for:', query)Dynamically toggle CSS classes:
vars:
isActive: false
hasError: false
html: |
<div class:active="() => $isActive"
class:error="() => $hasError">
Status indicator
</div>
<button @click="$isActive = !$isActive">
Toggle
</button>Dynamically set inline styles:
vars:
textColor: "blue"
fontSize: 16
html: |
<!-- Simple variable binding -->
<p style:color="textColor">Colored text</p>
<!-- Function binding -->
<p style:fontSize="() => $fontSize + 'px'">Sized text</p>Attach event handlers with on[Event] attributes:
html: |
<button onClick="() => this.increment()">Add</button>
<button onClick="() => this.reset()">Reset</button>
<input onInput="(e) => this.set_name(e.target.value)" />The UI automatically refreshes after event handlers execute.
Spiderpup provides shorthand syntax to reduce boilerplate and improve readability. All transformations happen at compile time, and the traditional syntax remains fully supported.
Use $var instead of this.get_var() and $var = value instead of this.set_var(value):
# Traditional syntax
methods:
increment: () => this.set_count(this.get_count() + 1)
double: () => this.set_count(this.get_count() * 2)
# Shorthand syntax
methods:
increment: () => { $count = $count + 1 }
double: () => { $count = $count * 2 }Works in methods, computed properties, watchers, lifecycle hooks, conditions, and event handlers:
computed:
doubleCount: () => $count * 2
greeting: () => "Hello, " + $name + "!"
watch:
count: (newVal) => console.log("Count is now:", newVal)
html: |
<if condition="() => $count > 10">
<p>Count is high!</p>
</if>Complex assignments are supported:
methods:
addItem: () => { $items = [...$items, "New Item"] }
reset: () => { $count = 0; $name = "Guest" }Use @click instead of onClick="() => ...":
# Traditional syntax
html: |
<button onClick="() => this.increment()">+1</button>
<button onClick="() => this.set_count(0)">Reset</button>
# Shorthand syntax
html: |
<button @click="increment()">+1</button>
<button @click="$count = 0">Reset</button>The @event syntax:
- Converts
@clicktoonClick,@mouseovertoonMouseover, etc. - Automatically wraps the value in
() =>if not already an arrow function - Combines well with
$varand implicitthis
All standard DOM events work: @click, @input, @change, @mouseover, @keydown, etc.
Bare method calls are automatically prefixed with this.:
# Traditional syntax
html: |
<button onClick="() => this.increment()">+1</button>
<button onClick="() => this.reset()">Reset</button>
# Shorthand syntax (implicit this)
html: |
<button @click="increment()">+1</button>
<button @click="reset()">Reset</button>JavaScript globals (console, Math, JSON, Date, fetch, Promise, etc.) and arrow function parameters are not prefixed:
methods:
log: () => console.log("Count:", $count) # console is not prefixed
process: (items) => items.map(x => x * 2) # items and x are not prefixedBefore (traditional):
vars:
count: 0
name: "World"
methods:
increment: () => this.set_count(this.get_count() + 1)
reset: () => this.set_count(0)
computed:
greeting: () => "Hello, " + this.get_name()
html: |
<p>{greeting}</p>
<p>Count: {count}</p>
<button onClick="() => this.increment()">+1</button>
<button onClick="() => this.set_count(this.get_count() - 1)">-1</button>
<button onClick="() => this.reset()">Reset</button>
<input bind="name" />
<if condition="() => this.get_count() > 5">
<p>Count is high!</p>
</if>After (shorthand):
vars:
count: 0
name: "World"
methods:
increment: () => { $count = $count + 1 }
reset: () => { $count = 0 }
computed:
greeting: () => "Hello, " + $name
html: |
<p>{greeting}</p>
<p>Count: {count}</p>
<button @click="increment()">+1</button>
<button @click="$count = $count - 1">-1</button>
<button @click="reset()">Reset</button>
<input bind="name" />
<if condition="() => $count > 5">
<p>Count is high!</p>
</if>All traditional syntax continues to work:
# You can mix styles freely
methods:
increment: () => { $count = $count + 1 } # shorthand
decrement: () => this.set_count(this.get_count() - 1) # traditional
html: |
<button @click="increment()">+1 (shorthand)</button>
<button onClick="() => this.decrement()">-1 (traditional)</button>Use <if>, <elseif>, and <else> tags:
html: |
<if condition="() => $count < 10">
<p>Count is small</p>
</if>
<elseif condition="() => $count < 50">
<p>Count is medium</p>
</elseif>
<else>
<p>Count is large</p>
</else>Add animations when conditionals change:
html: |
<if condition="() => $isVisible" transition="fade">
<div>This content fades in/out</div>
</if>
<if condition="() => $isOpen" transition="slide">
<div>This content slides in/out</div>
</if>Built-in transitions: fade, slide
Use <for> with static arrays or dynamic functions:
html: |
<!-- Static array -->
<for items="[1, 2, 3]">
<div textContent="(mod, item, idx) => `Item ${idx}: ${item}`"></div>
</for>
<!-- Dynamic from vars (shorthand) -->
<for items="$items">
<div textContent="(mod, item, idx) => item.toUpperCase()"></div>
</for>
<!-- Dynamic from vars (traditional) -->
<for items="() => this.get_items()">
<div textContent="(mod, item, idx) => item.toUpperCase()"></div>
</for>import:
button: /components/button.yaml
card: /components/card.yamlUse the namespace as the tag name, passing vars as attributes:
html: |
<button label="Click me" color="blue" />
<card title="My Card">
<p>Card content here</p>
</card>Components are just pages that receive attributes as vars:
# components/button.yaml
title: Button Component
vars:
label: "Button"
color: "gray"
css: |
button { padding: 10px 20px; }
html: |
<button style="background-color: {color}">{label}</button>Pass content into components:
# components/card.yaml
title: Card Component
vars:
title: "Card"
html: |
<div class="card">
<h2>{title}</h2>
<div class="card-content">
<slot/>
</div>
</div>Usage:
import:
card: /components/card.yaml
html: |
<card title="My Card">
<p>This content goes into the slot!</p>
<button>Action</button>
</card>Access DOM elements and component instances directly:
vars:
value: ""
methods:
focusInput: () => this.refs.myInput.focus()
clearInput: |
() => {
this.refs.myInput.value = '';
this.refs.myInput.focus();
}
html: |
<input ref="myInput" bind="value"/>
<button @click="focusInput()">Focus</button>
<button @click="clearInput()">Clear</button>For components, ref gives you the component instance:
html: |
<myComponent ref="comp"/>
<button onClick="() => this.refs.comp.someMethod()">Call Method</button>Execute code when components mount or unmount:
lifecycle:
onMount: |
() => {
console.log('Component mounted!');
this.refs.myInput.focus();
}
onDestroy: |
() => {
console.log('Component destroyed!');
// Cleanup timers, listeners, etc.
}Enable communication between components using a pub/sub pattern:
# Component A - broadcasts messages
methods:
notifyAll: |
() => {
this.broadcast('user-updated', { id: 123, name: 'John' });
}
sendAlert: |
() => {
this.broadcast('alert', { type: 'warning', message: 'Something happened!' });
}
html: |
<button @click="notifyAll()">Notify All</button># Component B - receives messages
lifecycle:
onMount: |
() => {
this.receive('user-updated', (data, sender) => {
console.log('User updated:', data);
$userName = data.name;
this.refresh();
});
this.receive('alert', (data) => {
alert(data.message);
});
}Key points:
broadcast(channel, data)sends to all modules except the senderreceive(channel, callback)registers a handler for a channel- Callback receives
(data, senderModule)arguments - Multiple receivers can listen to the same channel
- Useful for cross-component state sync, notifications, events
Emit events that propagate up the component hierarchy (like DOM events):
# Child component - emits events
methods:
handleClick: |
() => {
// Emit event that bubbles up to parents
this.emit('item-selected', { id: 42, name: 'Example' });
}
handleDelete: |
() => {
this.emit('item-deleted', { id: 42 });
}
html: |
<button @click="handleClick()">Select</button>
<button @click="handleDelete()">Delete</button># Parent component - handles events from children
import:
childItem: /components/child-item.yaml
lifecycle:
onMount: |
() => {
// Handle event and allow it to continue bubbling
this.on('item-selected', (event) => {
console.log('Item selected:', event.data);
// Event continues to bubble up
});
// Handle event and stop propagation
this.on('item-deleted', (event) => {
console.log('Item deleted:', event.data);
event.stopPropagation(); // Stop bubbling here
// Or: return false; // Also stops propagation
});
}
html: |
<div>
<childItem/>
<childItem/>
</div>Event object properties:
event.name- The event nameevent.data- The data passed to emit()event.source- The module that emitted the eventevent.stopPropagation()- Stop the event from bubbling further
Methods:
emit(name, data)- Emit an event that bubbles upon(name, handler)- Register an event handleroff(name, handler?)- Remove event handler(s)
Build single-page applications with client-side navigation.
title: My App
import:
home: /pages/home.yaml
about: /pages/about.yaml
user: /pages/user.yaml
routes:
/: home
/about: about
/user/:id: user
html: |
<nav>
<link to="/">Home</link>
<link to="/about">About</link>
<link to="/user/123">User 123</link>
</nav>
<router-view/>Parameters in routes (:id) are passed as vars to the component:
# pages/user.yaml
title: User Profile
vars:
id: ""
lifecycle:
onMount: () => console.log('Viewing user:', this.get_id())
html: |
<h1>User Profile</h1>
<p>User ID: {id}</p>Use <link to="..."> for client-side navigation (no page reload):
html: |
<link to="/about">Go to About</link>
<link to="/user/456">View User 456</link>title: Todo App
css: |
.completed { text-decoration: line-through; }
less: |
@primary: #3498db;
.todo-app {
max-width: 400px;
input {
border: 2px solid @primary;
padding: 8px;
}
button {
background: @primary;
color: white;
&:hover {
background: darken(@primary, 10%);
}
}
}
vars:
todos: []
newTodo: ""
methods:
addTodo: |
() => {
const todo = $newTodo;
if (todo) {
$todos.push({ text: todo, done: false });
$newTodo = '';
}
}
html: |
<div class="todo-app">
<h1>Todo List</h1>
<input bind="newTodo" placeholder="Enter a todo" />
<button @click="addTodo()">Add</button>
<for items="$todos">
<div textContent="(mod, item) => item.text"></div>
</for>
</div># Default (port 5000)
perl webserver.pl
# Development mode with hot reload
SPIDERPUP_DEV=1 perl webserver.pl
# With custom root path prefix
perl webserver.pl --root /myapp
# Demo mode (shows parsed HTML structure)
perl webserver.pl --demoSpiderpup automatically caches compiled HTML to improve performance:
- Cache stored in
/tmp/spiderpup_cache/ - Tracks all YAML dependencies (including imports)
- Automatically invalidates when any referenced file changes
- Works across server restarts
YAML parsing or compilation errors display a styled error overlay instead of crashing:
- Shows error message and location
- Styled overlay with dark theme
- Still allows hot reload to retry after fixes
Runtime JavaScript errors show a dismissible overlay:
- Catches uncaught exceptions
- Catches unhandled promise rejections
- Shows error message and stack trace
- Click "Dismiss" to close
| Feature | Spiderpup | React | Vue | BackdraftJS |
|---|---|---|---|---|
| Template syntax | YAML + HTML | JSX | SFC | Pure JS |
| Virtual DOM | No | Yes | Yes | No |
| Build step | None | Required | Optional | None |
| File size | ~2KB JS | ~40KB | ~30KB | ~2KB |
| Styling | LESS built-in | External | Scoped CSS | External |
| Routing | Built-in | External | External | None |
| Hot reload | Built-in | External | Built-in | None |