Skip to content

Instantly share code, notes, and snippets.

@dadhi
Last active December 25, 2025 21:07
Show Gist options
  • Select an option

  • Save dadhi/2c07a5f23a5716462fd733c7020918db to your computer and use it in GitHub Desktop.

Select an option

Save dadhi/2c07a5f23a5716462fd733c7020918db to your computer and use it in GitHub Desktop.
Dmax is Datastar.js inspired, fast, small, no-build, no-magic, declarative web lib for the page interactivity based on signals and the html native data- attributes
  1. data-class
  2. data-see

I decided to pause here an settle/rethink on the syntax to ensure the stable basis for the moving forward:

  1. I misled you with the case. I mean kebab-case used in attributes will automatically be converted to camelCase, snake_case should not be converted when used in expression.
  2. Syntax. 2.1. Events now start with @ instead of on-. We are saving the 2 symbols. 2.2. Action syntax
  • Basis are data-get, -post, -put, -patch, -delete
  • Multiple input params (signals) start with : , eg. :post-id:foo.bar.baz The input param mods __uri to pass as uri (default for get, delete), __body (default for post, put, patch), __header.name to pass as header wuth the name. Special params are form! and file! with exclamation form.
  • Single output parameter starts with :+, eg :+posts. Mods are __merge (default), __replace, __append (for array), __prepend (for array).
  • Multiple action state info is starts with :?, eg :?fetching. Mods are __busy (default), __done, __ok, __err, __code, __all (all of the former).
  • Options starts with :^, eg. :^get-options. The signals is object like this { headers: {}, timeout, retry cancellation, ...} We may TBD all except headers. Multiple options possible. The special names are: json!, html!, sse!, js! corresponding to the Content-Type header; no-cache! for cache control. We may add others in TBD.
  • Normally action ends with event, eg. @click__prevent, @delay-1000, @interval-500, @change-foo__true @change-bar-baz__eq.0, etc. (TBD)
  • Action value contain url
data-iter:posts:post:pidx#posts-tmpl

Ok. Let streamline, complete grammar the grammar and I guide you with parsing instruction:

  1. data-sub

data-sub(:(signal|#.prop))((@(signal|#.event))(__mod(.val)?))*='js exp'

  • ':signal' - zero, one or many signals; represents target for the right js expr assignment; example for-bar.baz

  • ':#.prop' zero, one, or many element props; represents target for the right js exp: example :#.value for the element where data-sub is defined, :#some-el-id.style.color is nested prop on the elem with id #some-elem, :# is the default target prop of the el where data-sub defined, eg value, checked, selectedOptions, textContent, :#my-input the default prop of the elem with id #my-elem

  • if zero targets is specified then the js expr will be evaluated for its side effect data-sub#='alert(1)' raises alert each time the default prop of the defined el changes, data-sub@foo='bar.baz = 3' - changes bar.baz on the foo change.

  • @signal - zero, one or many subscriptions/triggers on the signal change; data-set@foo@bar.baz

  • @#.event - zero, one or many html events; example @#.click for the el where data-sub is defined, @#elem-id.mouseover is the event of @#elem-id; @# is the default event of the el: input, change. If triggered then not not null event is available in js expr; @#window.event and @#document.event for window and document events respectively. There are also two sintetic events without elem: interval and delay

  • if no event or signal trigger is specified then js exp is evaluated once on element and its result assigned to all target, if no targets then exp id evaluated for its sideeffects, example data-sub='console.log(42)' - side effect, data-sub:#='42' - assign '42' default el prop; data-sub:#my-input:foo:#.style.width='42' - assign '42' to #my-input default prop, also to foo signal, also to current elem style.width.

  • triggers mods are __immediate (default for @signal), __notimmediate (default for @#.),

__once, __debounce.numOfMs, __throttle.numOfMs,

__prevent (for preventing default behavior)

Parse notes:

  • : denotes start of target (signal or prop)

-- if next char is # then it is prop otherwise signal

--- if signal, read untill : or @ the name of the signal. Signal may be nested, so each . will denote of the signal name in the nested chain. Signal name cannot be empty -> error.

--- if prop, then read until . or : or @ the id of prop elem. If its empty then it is current element, otherwise it is id of element. If we read . then read the prop name until the : or @. Property name can be nested (style.color) each new . denote the end of the prop in the nested chain.

If we do not read the . after elem name and found : or @ instead, then the property is default for this element. The case with empty elem and no prop is valid @# and denotes current element with default prop.

  • similar @ denotes start of trigger (signal or event). Property cannot be a trigger, the found name we consider an event -> error if not found.

-- if the next char is # it is event otherwise a signal

--- if it is signal we read its name until __ or : or @. The name can be nested chain delim by . If the name ends on __ next we read a mod name and possible value after the dot. We read the mod (collecting its name and value) until next mod __ or : or @.

--- if it is event read its element id until __ or . or : or @ The id may be empty (curr elem), maybe window or document, othrwise an id of the elem. If we found . then read event name (may be chained with .) untill __ or : or @. Event name may empty (but not for window or document -> error) meaning the default event. If the next was __ then read the mods as above the same as mods for signals. The empty id and event is valid @# meaning default event of the curr elem.

  1. data-sync

A sugar consisting of 1 or 2 targets with the same grammar as in data-sub for targets but with optional mods from triggers (because those are targets and triggers at the same time)

  • 2 targets may be signals or props means that target is writable and the other target changed (signal changed or prop elem default event raised) then the first target (signal or prop) will be set to the value of the first, and vice versa. If that target is not writable then it won't be set (the update should be ignored). Infinite loop of updates should be prevented by comparing that value is not changed and preventing update).

  • If one the targets is signal, then the should be updated immediatly when the elem with data-sync loaded (unless __noimmediate is specified).

  • It is possible that a single target is specified. This is a special case means the second target will be default prop/default event of the current element.

System config: do not use regexp, array includes and split. Use indexof and string iteration to find token boundaries. It is fine to use indexof multiple times for different chars (as we do not have indexofany). But remember that indexof is heavily optimized by v8.

<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>dmax v0.3</title>
<style>
body{font-family:sans-serif;padding:16px;line-height:1.5;max-width:900px;margin:0 auto;background:#f8f9fa}
button{margin:4px;padding:8px 12px;cursor:pointer;border:1px solid #007bff;background:#007bff;color:white;border-radius:4px}
button:hover{background:#0056b3}
input,select{padding:8px;margin:4px;border:1px solid #ccc;border-radius:4px}
pre{background:#222;color:#4af626;padding:12px;border-radius:4px;position:sticky;top:10px;max-height:300px;overflow:auto;font-size:12px}
section{background:white;margin-bottom:20px;padding:16px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.1)}
section h3{margin-top:0;color:#333;border-bottom:2px solid #007bff;padding-bottom:8px}
.preview{padding:12px;border:2px solid #ddd;margin:8px 0;border-radius:6px;font-weight:bold}
label{display:inline-block;margin:8px 0;font-weight:500}
</style>
</head>
<body>
<div data-def='{
"user": {
"name": "Alice",
"age": 25,
"ui": {
"theme-color": "#007bff",
"is-active": true,
"font-size": 16
}
},
"count": 10,
"doubled": 20,
"message": "Hello World"
}'></div>
<section>
<h3>1. Deep Nested Signal Sync</h3>
<label>User Name (nested path):</label>
<input type="text" data-sync:user.name>
<p>Hello, <strong data-sync:user.name></strong>!</p>
<label>Age:</label>
<input type="number" data-sync:user.age>
<p>Age: <span data-sync:user.age></span> years old</p>
</section>
<section>
<h3>2. Style & Boolean Deep Sync</h3>
<label>Theme Color:</label>
<input type="color" data-sync:user.ui.theme-color>
<div class="preview" data-sub:#.style.color@user.ui.theme-color="user.ui.themeColor">
This text color syncs to theme-color signal!
</div>
<label>Font Size: <span data-sync:user.ui.font-size></span>px</label>
<input type="range" min="12" max="32" data-sync:user.ui.font-size>
<p data-sub:#.style.font-size@user.ui.font-size="`${user.ui.fontSize}px`">
This text resizes dynamically!
</p>
<label>
<input type="checkbox" data-sync:user.ui.is-active> Active Status
</label>
<p>Status: <strong data-sub:#@user.ui.is-active="user.ui.isActive ? '🟢 ONLINE' : '🔴 OFFLINE'"></strong></p>
</section>
<section>
<h3>3. Multiple Triggers & Targets</h3>
<button data-sub:count@#.click="count + 1">+1</button>
<button data-sub:count@#.click="count - 1">-1</button>
<button data-sub:count@#.click="0">Reset</button>
<p>Count: <strong data-sub:#@count="count"></strong></p>
<p>Multi-target (updates 3 places):
<span data-sub:#:doubled@count="count * 2"></span>
</p>
</section>
<section>
<h3>4. Side Effects & Multiple Triggers</h3>
<button id="btn1">Button 1</button>
<button id="btn2">Button 2</button>
<p>Side effect logs: <span data-sub@count@#btn1.click@#btn2.click="console.log('Triggered!', count) || '✓ Check console'"></span></p>
<p>Multi-trigger display: <strong data-sub:#@count@#btn1.click@#btn2.click="count + ' (updated)'"></strong></p>
</section>
<section>
<h3>5. Cross-Element Property Sync</h3>
<label>Source Input:</label>
<input id="src" placeholder="Type here..." data-sync:message>
<label>Mirror (via data-sub):</label>
<p class="preview" data-sub:#@#src.input="document.getElementById('src').value"></p>
<label>Another Mirror:</label>
<input id="mirror" readonly data-sub:#.value@#src.input="document.getElementById('src').value">
</section>
<section>
<h3>6. Window Events & Intervals</h3>
<p>Window width: <span data-sub:#@#window.resize="window.innerWidth + 'px'"></span></p>
<p>Current time: <span data-sub:#@#interval.1000="new Date().toLocaleTimeString()"></span></p>
</section>
<section>
<h3>7. Default Props & Events</h3>
<input id="inp1" placeholder="Default event (input)">
<p>Mirror using @#: <span data-sub:#@#inp1="document.getElementById('inp1').value"></span></p>
<button id="btn3">Click me</button>
<p>Default event @#: <span data-sub:#@#btn3="'Clicked!'"></span></p>
</section>
<h4>State Tree (Live Debug):</h4>
<pre data-debug></pre>
<script>
/*
dmax v0.3 (~3kb unzipped, ~1.3kb min+gzip)
------------------------------------------
Subscription Engine: Explicit reactivity, no magic, maximum performance.
Grammar (Complete):
1. data-sub:
data-sub(:(signal|#.prop))*((@(signal|#.event))(__mod(.val)?)*)*='js expr'
Targets (zero or more):
- :signal → user, user.name, foo.bar.baz (nested signals)
- :#.prop → #.value, #elem.value, #.style.color, #elem.style.font-size
- :# → default prop of current element
- :#elem → default prop of element with id
- Zero targets → side effect only
Triggers (zero or more):
- @signal → foo, user.name (signal changes)
- @#.event → #.click, #elem.click, #.mouseover
- @# → default event of current element
- @#elem → default event of element with id
- @#window.event, @#document.event → global events
- @#interval.ms, @#delay.ms → synthetic events
- Zero triggers → evaluate once on init
Modifiers:
- __immediate (default for @signal)
- __notimmediate (default for @#.event)
- __once
- __debounce.ms
- __throttle.ms
- __prevent (preventDefault)
IMPORTANT: Use kebab-case for properties in attributes!
✅ #.font-size → fontSize
✅ #.text-content → textContent
❌ #.fontSize → fontsize (wrong!)
2. data-sync:
data-sync:target1[:target2][__mod]*
Sugar for bidirectional sync between 2 targets (signals or props).
- One target → second is default prop/event of current element
- Two targets → bidirectional sync with writable check
- Prevents infinite loops via value comparison
- Signal targets get __immediate by default
Examples:
data-sub:count@#.click="count + 1"
data-sub:#@user.name="user.name"
data-sub:#:doubled:foo@count="count * 2"
data-sub@count="console.log(count)"
data-sub:#@#src.input="ev.target.value"
data-sub:#@#="'default'"
data-sync:user.name
data-sync:user.name:#.value
Performance:
- Function caching
- Key caching
- Zero regex in hot paths
- No array.includes, no split in parser
- indexOf optimized by V8
- with($) scope for signals
*/
(()=>{
const S=new Map(), subs=new Map(), keyCache=new Map(), fnCache=new Map();
const debug=document.querySelector('[data-debug]');
// Optimized kebab→camel (no regex, cached)
const toCamel = s => {
if(!s || s.indexOf('-') === -1) return s;
if(keyCache.has(s)) return keyCache.get(s);
let result = '', i = 0;
while(i < s.length){
if(s[i] === '-' && i + 1 < s.length){
result += s[i + 1].toUpperCase();
i += 2;
} else {
result += s[i];
i++;
}
}
keyCache.set(s, result);
return result;
};
// Smart property detection
const getAutoProp = el => {
const tag = el.tagName;
const type = el.type;
if(type === 'checkbox' || type === 'radio') return 'checked';
if(tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return 'value';
return 'textContent';
};
// Smart event detection
const getAutoEvent = el => {
const tag = el.tagName;
const type = el.type;
if(type === 'checkbox' || type === 'radio') return 'change';
if(tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return tag === 'SELECT' ? 'change' : 'input';
return 'click';
};
const updateDebug = () => {
if(debug) debug.textContent = JSON.stringify(Object.fromEntries(S), null, 2);
};
const get = p => {
const parts = p.split('.');
let v = S.get(toCamel(parts[0]));
for(let i = 1; i < parts.length; i++) v = v?.[toCamel(parts[i])];
return v;
};
const emit = p => {
const root = toCamel(p.split('.')[0]);
const handlers = subs.get(root);
if(handlers) handlers.forEach(fn => fn());
updateDebug();
};
const set = (p, v) => {
const parts = p.split('.');
const root = toCamel(parts[0]);
const current = get(p);
if(JSON.stringify(current) === JSON.stringify(v)) return;
if(parts.length === 1){
S.set(root, v);
} else {
const base = JSON.parse(JSON.stringify(S.get(root)));
let t = base;
for(let i = 1; i < parts.length - 1; i++){
const key = toCamel(parts[i]);
t = t[key];
}
t[toCamel(parts[parts.length - 1])] = v;
S.set(root, base);
}
emit(root);
};
const setProp = (el, path, val) => {
const parts = path.split('.');
let t = el;
for(let i = 0; i < parts.length - 1; i++) t = t[toCamel(parts[i])];
const last = toCamel(parts[parts.length - 1]);
if(t && t[last] !== val) t[last] = val;
};
const compile = body => {
if(fnCache.has(body)) return fnCache.get(body);
const fn = new Function('$', 'el', 'ev', `with($){ try{ return ${body} }catch(e){console.error(e)} }`);
fnCache.set(body, fn);
return fn;
};
// Parse data-sub attribute name (not value!)
const parseDataSub = attr => {
const s = attr;
let i = 8; // Skip 'data-sub'
const targets = [], triggers = [];
// Parse targets (starting with :)
while(i < s.length && s[i] === ':'){
i++; // skip :
if(i >= s.length) break;
if(s[i] === '#'){
// Property target
i++; // skip #
let elemId = '', propPath = '';
// Read element id until . or : or @ or end
while(i < s.length){
const c = s[i];
if(c === '.' || c === ':' || c === '@') break;
elemId += c;
i++;
}
// If we found ., read property path
if(i < s.length && s[i] === '.'){
i++; // skip .
while(i < s.length){
const c = s[i];
if(c === ':' || c === '@') break;
propPath += c;
i++;
}
}
targets.push({type: 'prop', elemId, propPath});
} else {
// Signal target
let signalName = '';
while(i < s.length){
const c = s[i];
if(c === ':' || c === '@') break;
signalName += c;
i++;
}
if(!signalName){
console.error('Empty signal name in target');
return null;
}
targets.push({type: 'signal', name: signalName});
}
}
// Parse triggers (starting with @)
while(i < s.length && s[i] === '@'){
i++; // skip @
if(i >= s.length) break;
if(s[i] === '#'){
// Event trigger
i++; // skip #
let elemId = '', eventName = '';
// Read element id until . or @ or _ or : or end
while(i < s.length){
const c = s[i];
if(c === '.' || c === '@' || c === '_' || c === ':') break;
elemId += c;
i++;
}
// If we found ., read event name
if(i < s.length && s[i] === '.'){
i++; // skip .
while(i < s.length){
const c = s[i];
if(c === '@' || c === '_' || c === ':') break;
eventName += c;
i++;
}
}
// Parse mods
const mods = {};
while(i < s.length && i + 1 < s.length && s[i] === '_' && s[i+1] === '_'){
i += 2; // skip __
let modName = '', modVal = '';
while(i < s.length){
const c = s[i];
if(c === '.' || c === '@' || c === '_' || c === ':') break;
modName += c;
i++;
}
if(i < s.length && s[i] === '.'){
i++; // skip .
while(i < s.length){
const c = s[i];
if(c === '@' || c === '_' || c === ':') break;
modVal += c;
i++;
}
}
mods[modName] = modVal ? +modVal : 1;
}
triggers.push({type: 'event', elemId, eventName, mods});
} else {
// Signal trigger
let signalName = '';
while(i < s.length){
const c = s[i];
if(c === '@' || c === '_' || c === ':') break;
signalName += c;
i++;
}
if(!signalName){
console.error('Empty signal name in trigger');
return null;
}
// Parse mods
const mods = {};
while(i < s.length && i + 1 < s.length && s[i] === '_' && s[i+1] === '_'){
i += 2; // skip __
let modName = '', modVal = '';
while(i < s.length){
const c = s[i];
if(c === '.' || c === '@' || c === '_' || c === ':') break;
modName += c;
i++;
}
if(i < s.length && s[i] === '.'){
i++; // skip .
while(i < s.length){
const c = s[i];
if(c === '@' || c === '_' || c === ':') break;
modVal += c;
i++;
}
}
mods[modName] = modVal ? +modVal : 1;
}
triggers.push({type: 'signal', name: signalName, mods});
}
}
return {targets, triggers};
};
const setupSub = (el, attr, body) => {
const parsed = parseDataSub(attr);
if(!parsed) return;
const {targets, triggers} = parsed;
const fn = compile(body);
const handler = ev => {
const result = fn(Object.fromEntries(S), el, ev);
// Apply to all targets
if(targets.length === 0){
// Side effect only
return;
}
for(let t of targets){
if(t.type === 'signal'){
set(t.name, result);
} else {
// Property
const targetEl = t.elemId ? document.getElementById(t.elemId) : el;
if(!targetEl) continue;
const propPath = t.propPath || getAutoProp(targetEl);
setProp(targetEl, propPath, result);
}
}
};
// Register triggers
for(let t of triggers){
if(t.type === 'signal'){
const root = toCamel(t.name.split('.')[0]);
if(!subs.has(root)) subs.set(root, []);
subs.get(root).push(handler);
if(!t.mods || !t.mods.notimmediate) handler(); // immediate by default for signals
} else {
// Event
if(t.elemId === 'interval' || t.elemId === 'delay'){
const ms = +t.eventName;
if(t.elemId === 'interval') setInterval(handler, ms);
else setTimeout(handler, ms);
} else if(t.elemId === 'window'){
if(!t.eventName){
console.error('Window event name required');
continue;
}
window.addEventListener(t.eventName, handler);
} else if(t.elemId === 'document'){
if(!t.eventName){
console.error('Document event name required');
continue;
}
document.addEventListener(t.eventName, handler);
} else {
const targetEl = t.elemId ? document.getElementById(t.elemId) : el;
if(!targetEl){
console.error('Element not found:', t.elemId);
continue;
}
const eventName = t.eventName || getAutoEvent(targetEl);
targetEl.addEventListener(eventName, handler);
}
if(t.mods && t.mods.immediate) handler();
}
}
// If no triggers, run once
if(triggers.length === 0) handler();
};
const init = () => {
const all = document.querySelectorAll('*');
// PHASE 1: Definitions
all.forEach(el => {
for(let a of el.attributes){
if(a.name !== 'data-def' && a.name.indexOf('data-def:') !== 0) continue;
const colonIdx = a.name.indexOf(':');
const name = colonIdx === -1 ? '' : a.name.substring(colonIdx + 1);
const camelName = toCamel(name);
if(camelName === 'ev' || camelName === 'el'){
console.error('Reserved signal name:', name);
continue;
}
if(!name){
const o = JSON.parse(a.value);
for(let k in o){
const camelK = toCamel(k);
if(camelK === 'ev' || camelK === 'el'){
console.error('Reserved signal name:', k);
continue;
}
S.set(camelK, o[k]);
}
} else {
S.set(camelName, Function('return ' + a.value)());
}
}
});
// PHASE 2: Subscriptions
all.forEach(el => {
for(let a of el.attributes){
if(a.name.indexOf('data-sub') === 0){
setupSub(el, a.name, a.value);
} else if(a.name.indexOf('data-sync:') === 0){
// Simple data-sync implementation
const parts = a.name.substring(10).split(':');
const signal = parts[0];
const propPath = parts[1];
const signalAccessor = signal.split('.').map(toCamel).join('.');
const actualProp = propPath ? propPath.substring(2) : getAutoProp(el);
const event = getAutoEvent(el);
// Signal → Property
setupSub(el, `data-sub:#${propPath||''}@${signal}`, signalAccessor);
// Property → Signal (if writable)
const canWrite = el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA';
if(canWrite){
setupSub(el, `data-sub:${signal}@#.${event}`, `el.${toCamel(actualProp)}`);
}
}
}
});
updateDebug();
};
if(document.readyState === 'loading'){
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment