@zvndev/darkmown 0.6.0 → 0.7.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/AGENTS.md +11 -1
- package/CHANGELOG.md +8 -0
- package/README.md +28 -0
- package/docs/spec-alignment.md +1 -0
- package/package.json +1 -1
- package/site/pages/docs/index.wd +15 -0
- package/site/pages/reactive.skin +30 -0
- package/site/pages/reactive.wd +16 -1
- package/src/compiler.js +27 -3
- package/src/runtime.js +23 -0
package/AGENTS.md
CHANGED
|
@@ -41,7 +41,17 @@ Count: { count }
|
|
|
41
41
|
:button "Add item" -> cart += {"id": 1, "name": "Sticker"}
|
|
42
42
|
:button "Reset" -> count = 0
|
|
43
43
|
```
|
|
44
|
-
Button actions are a **fixed whitelist**: `x++`, `x--`, `x += <number>`, `list += <json>`, `x = <json>`. Target must be declared `:state`.
|
|
44
|
+
Button actions are a **fixed whitelist**: `x++`, `x--`, `x += <number>`, `list += <json>`, `x = <json>`. Target must be declared `:state`. Literal values are JSON. **`:button` is for state actions only** — for a link styled as a button, write raw HTML `<a class="btn" href="…">`.
|
|
45
|
+
|
|
46
|
+
**Per-row actions (inside a reactive `@loop … into item`):** two actions reference the current row instead of a JSON literal —
|
|
47
|
+
```wd
|
|
48
|
+
@loop products into product
|
|
49
|
+
:button "Add to cart" -> cart += product ← append a COPY of this row to another :state list
|
|
50
|
+
:::
|
|
51
|
+
@loop cart into line
|
|
52
|
+
:button "Remove" -> cart remove line ← drop this row from the list being looped
|
|
53
|
+
```
|
|
54
|
+
`cart += product` is the only way to carry a loop item into another list (literals still can't reference rows). `<list> remove <item>`: `<list>` must be the loop's own `:state` source and `<item>` the loop variable (both compile-checked). This is the canonical add-to-cart / remove-line / delete-todo pattern — no `.js` needed. Removal targets the exact row, so it is correct even under a `where` filter.
|
|
45
55
|
|
|
46
56
|
### Conditionals
|
|
47
57
|
```wd
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Darkmown are documented here. Versions follow [semver](https://semver.org); pre-1.0 minor versions may contain breaking changes.
|
|
4
4
|
|
|
5
|
+
## 0.7.0 — 2026-06-13
|
|
6
|
+
|
|
7
|
+
- **Per-row actions in reactive loops.** A `:button` inside `@loop … into item` can now act on its own row — the canonical add-to-cart / remove-line / delete-todo pattern, with no hand-written JavaScript:
|
|
8
|
+
- `cart += item` appends a **copy** of the current row to another `:state` list. This is the only way to carry a loop item into another list (literal action values still can't reference rows); the copy means adding the same source row twice yields two independent lines.
|
|
9
|
+
- `<list> remove <item>` removes the current row from the list being looped. `<list>` must be that loop's own `:state` source and `<item>` the loop variable — both validated at compile time.
|
|
10
|
+
The runtime resolves the clicked row by stamping each reconciled node with its item, so removal targets the exact row and stays correct even when the loop is filtered with `where` (index-based removal would not). Closes the last gap blocking a real store demo.
|
|
11
|
+
- Live demo: the reactive page's filtering section is now a full mini-shop — search products, add to cart, remove lines — all composing on one page.
|
|
12
|
+
|
|
5
13
|
## 0.6.0 — 2026-06-13
|
|
6
14
|
|
|
7
15
|
- **State-driven list filtering — `@loop … where <predicate>`.** Filter any loop with conditions that join via `and` / `or`. Operators: `==` `!=` `<` `<=` `>` `>=` and `contains` (case-insensitive substring). Operands are item paths, declared `:state`, numbers, or `"strings"` — a compile-time whitelist (no expressions, no `eval`; `constructor`/`prototype`/`__proto__` rejected in compiler and runtime). **The predicate decides reactivity:** an item-only predicate filters at build time and the page stays zero-JS; a predicate that reads `:state` compiles to a reactive filtered loop driven by the keyed reconciler (rows are baked in for static JSON sources). This replaces the fragile DOM-toggle escape hatch — the #1 gap surfaced by the agent-eval benchmark.
|
package/README.md
CHANGED
|
@@ -101,6 +101,34 @@ Operators: `==` `!=` `<` `<=` `>` `>=`, plus `contains` for case-insensitive sub
|
|
|
101
101
|
|
|
102
102
|
`:bind <state>` renders an `<input>` wired two-way to a `:state` value — typing updates the state, and the state reflects back into the field. It accepts `type=` (default `text`), `placeholder=`, `autocomplete=`, and the `required` / `autofocus` flags.
|
|
103
103
|
|
|
104
|
+
### Editable lists — per-row actions
|
|
105
|
+
|
|
106
|
+
A `:button` inside a reactive `@loop` can act on its own row. Two row actions exist:
|
|
107
|
+
|
|
108
|
+
```wd
|
|
109
|
+
:state products = [{"id": 1, "name": "Aurora", "price": 49}]
|
|
110
|
+
:state cart = []
|
|
111
|
+
|
|
112
|
+
@loop products into product
|
|
113
|
+
::: card
|
|
114
|
+
**{ product.name }** — ${ product.price }
|
|
115
|
+
:button "Add to cart" -> cart += product <- carry this row into another list
|
|
116
|
+
:::
|
|
117
|
+
@endloop
|
|
118
|
+
|
|
119
|
+
@loop cart into line
|
|
120
|
+
::: card
|
|
121
|
+
{ line.name }
|
|
122
|
+
:button "Remove" -> cart remove line <- drop this row from the looped list
|
|
123
|
+
:::
|
|
124
|
+
@endloop
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- `cart += <item>` appends a **copy** of the current row to another `:state` list, so adding the same product twice gives two independent lines.
|
|
128
|
+
- `<list> remove <item>` removes the current row from the list being looped. The `<list>` must be that loop's own `:state` source and `<item>` must be the loop variable — both checked at compile time. Removal targets the exact row, so it stays correct even when the loop is filtered with `where`.
|
|
129
|
+
|
|
130
|
+
That is a full add-to-cart / remove-line flow — and a to-do list with delete — in plain Markdown, no JavaScript.
|
|
131
|
+
|
|
104
132
|
## Sections
|
|
105
133
|
|
|
106
134
|
```wd
|
package/docs/spec-alignment.md
CHANGED
|
@@ -51,6 +51,7 @@ This audit compares the current Darkmown implementation with the original MARROW
|
|
|
51
51
|
- **Nested `:if` over loop items (v0.5.0):** conditionals nest inside reactive `@loop`, per row. Both fill paths fixed — build-time balanced `matchElement` recursion and runtime `fillItem` recursion.
|
|
52
52
|
- **State-driven list filtering — `@loop … where <predicate>`:** the #1 need surfaced by the agent-eval benchmark. Conditions (`field <op> value`, plus `contains`) join with `and` / `or` over a compile-time-validated whitelist — item paths, declared `:state`, numbers, strings; `constructor`/`prototype`/`__proto__` rejected, no `eval`. The source decides reactivity, matching the loop itself: an item-only predicate filters at build time and stays zero-JS; a predicate that reads `:state` compiles to a reactive filtered loop (rows baked in for static sources). This replaces the fragile DOM-toggle escape hatch with the keyed reconciler.
|
|
53
53
|
- **`:bind <state>`:** two-way `<input>` bound to a `:state` value — the input primitive that powers live search. Accepts `type=`, `placeholder=`, `autocomplete=`, `required`, `autofocus`.
|
|
54
|
+
- **Per-row actions in reactive loops:** a `:button` inside `@loop … into item` can act on its own row — `cart += item` appends a copy of the row to another `:state` list (the only way to carry a loop item into another list), and `list remove item` removes the row from the looped `:state` source (both names compile-checked against the enclosing loop). The runtime resolves the clicked row by stamping each reconciled node with its item, so removal is exact even under a `where` filter. This closes the last "store demo" gap noted in the design memory (per-row `:button` couldn't carry row data); `:if` string-equality remains the only listed gap, now partly covered by `@loop … where`.
|
|
54
55
|
- **`.skin` robustness:** `@media`/`@supports` at-rules wrap nested rules; the `font` shorthand passes through (size-led or containing `/`) while `font <stack>` still aliases to `font-family`; `/* … */` block comments and decorative divider lines are skipped.
|
|
55
56
|
- **Editor tooling:** VS Code extension (`editors/vscode`) ships `.wd` + `.skin` TextMate grammars, snippets, and folding, grammar-tested in CI.
|
|
56
57
|
- **`AGENTS.md`:** a model-facing build guide (shipped in the npm files array), tuned over a 5-round agent-eval benchmark that lifted build quality from 2.78→4.51/5.
|
package/package.json
CHANGED
package/site/pages/docs/index.wd
CHANGED
|
@@ -88,6 +88,21 @@ The predicate decides reactivity exactly like the source does: read only the row
|
|
|
88
88
|
|
|
89
89
|
`:bind <state>` renders an `<input>` wired two-way to a `:state` value — type and the list filters as you go.
|
|
90
90
|
|
|
91
|
+
### Editable lists with per-row actions
|
|
92
|
+
|
|
93
|
+
A `:button` inside a reactive loop can act on its own row — the basis for carts and to-do lists:
|
|
94
|
+
|
|
95
|
+
```wd
|
|
96
|
+
@loop products into product
|
|
97
|
+
:button "Add to cart" -> cart += product
|
|
98
|
+
:::
|
|
99
|
+
@loop cart into line
|
|
100
|
+
:button "Remove" -> cart remove line
|
|
101
|
+
:::
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`cart += product` appends a copy of the current row to another `:state` list; `cart remove line` removes the current row from the list being looped. Both are checked against the enclosing loop at compile time — no JavaScript.
|
|
105
|
+
|
|
91
106
|
## Interpolation
|
|
92
107
|
|
|
93
108
|
One syntax everywhere: `{ name }` or `{ name.path }`.
|
package/site/pages/reactive.skin
CHANGED
|
@@ -60,3 +60,33 @@ ul
|
|
|
60
60
|
|
|
61
61
|
.tally-card h3
|
|
62
62
|
color $warm
|
|
63
|
+
|
|
64
|
+
.card.product
|
|
65
|
+
display flex
|
|
66
|
+
align-items center
|
|
67
|
+
justify-content space-between
|
|
68
|
+
gap 1rem
|
|
69
|
+
border 1px solid $line
|
|
70
|
+
bg $panel
|
|
71
|
+
padding .6rem 1rem
|
|
72
|
+
margin .5rem 0
|
|
73
|
+
radius $radius
|
|
74
|
+
|
|
75
|
+
.card.product p
|
|
76
|
+
margin 0
|
|
77
|
+
|
|
78
|
+
.card.line
|
|
79
|
+
display flex
|
|
80
|
+
align-items center
|
|
81
|
+
justify-content space-between
|
|
82
|
+
gap 1rem
|
|
83
|
+
border 1px solid $line
|
|
84
|
+
padding .5rem 1rem
|
|
85
|
+
margin .4rem 0
|
|
86
|
+
radius $radius
|
|
87
|
+
|
|
88
|
+
.card.line button
|
|
89
|
+
bg transparent
|
|
90
|
+
color $warm
|
|
91
|
+
border 1px solid $line
|
|
92
|
+
padding .35rem .8rem
|
package/site/pages/reactive.wd
CHANGED
|
@@ -69,16 +69,31 @@ Each row picks one of three states from two nested conditionals, all in plain Ma
|
|
|
69
69
|
`@loop … where` filters a list, and because the predicate reads a `:state` value, the loop re-filters live as you type. `:bind` is the input bound two-way to that state. Type below and watch the list narrow — no event handlers, no JavaScript:
|
|
70
70
|
|
|
71
71
|
:state products = [{"id": 1, "name": "Aurora Lamp", "price": 49}, {"id": 2, "name": "Briza Fan", "price": 39}, {"id": 3, "name": "Cove Speaker", "price": 89}, {"id": 4, "name": "Dune Mug", "price": 12}, {"id": 5, "name": "Ember Kettle", "price": 64}]
|
|
72
|
+
:state cart = []
|
|
72
73
|
:state q = ""
|
|
73
74
|
|
|
74
75
|
:bind q placeholder="Search products…"
|
|
75
76
|
|
|
76
77
|
@loop products into p where p.name contains q
|
|
77
|
-
|
|
78
|
+
::: card .product
|
|
79
|
+
**{ p.name }** — ${ p.price }
|
|
80
|
+
:button "Add to cart" -> cart += p
|
|
81
|
+
:::
|
|
78
82
|
@endloop
|
|
79
83
|
|
|
80
84
|
The predicate whitelist is compile-time validated — item fields, declared state, numbers, and strings only. An item-only predicate (say `where p.price < 50`) would instead filter at build time and ship zero JavaScript.
|
|
81
85
|
|
|
86
|
+
### …and an editable cart
|
|
87
|
+
|
|
88
|
+
A `:button` inside a loop can act on its own row. **Add to cart** carries the row into another list; **Remove** drops a line — a whole add/remove flow with no JavaScript:
|
|
89
|
+
|
|
90
|
+
@loop cart into line
|
|
91
|
+
::: card .line
|
|
92
|
+
{ line.name } — ${ line.price }
|
|
93
|
+
:button "Remove" -> cart remove line
|
|
94
|
+
:::
|
|
95
|
+
@endloop
|
|
96
|
+
|
|
82
97
|
## Section-scoped state
|
|
83
98
|
|
|
84
99
|
Two sections can each own a state value named `tally` without colliding. Buttons resolve to the nearest scope:
|
package/src/compiler.js
CHANGED
|
@@ -705,7 +705,7 @@ function staticUnroll(rows, itemName, bodyLines, ctx) {
|
|
|
705
705
|
|
|
706
706
|
function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
707
707
|
ctx.comp.assets.runtime = true;
|
|
708
|
-
const templateCtx = { ...ctx, loopItem: itemName, scope: createScope(ctx.scope) };
|
|
708
|
+
const templateCtx = { ...ctx, loopItem: itemName, loopKey: key, scope: createScope(ctx.scope) };
|
|
709
709
|
const templateHtml = compileBody(bodyLines, templateCtx).trim();
|
|
710
710
|
|
|
711
711
|
let wrapperTag = "div";
|
|
@@ -933,17 +933,41 @@ function parseAction(raw, ctx) {
|
|
|
933
933
|
if (increment) return { op: "inc", target: resolveTarget(increment[1]) };
|
|
934
934
|
const decrement = expression.match(/^([A-Za-z_$][\w$]*)--$/);
|
|
935
935
|
if (decrement) return { op: "dec", target: resolveTarget(decrement[1]) };
|
|
936
|
+
// Per-row: carry the current loop item into another list — `cart += product`.
|
|
937
|
+
// Only inside a reactive @loop; the runtime resolves the row from the DOM.
|
|
936
938
|
const add = expression.match(/^([A-Za-z_$][\w$]*)\s*\+=\s*(.+)$/);
|
|
937
939
|
if (add) {
|
|
940
|
+
const rhs = add[2].trim();
|
|
941
|
+
if (ctx.loopItem && rhs === ctx.loopItem) {
|
|
942
|
+
const target = resolveTarget(add[1]);
|
|
943
|
+
if (!Array.isArray(ctx.comp.state.get(target))) {
|
|
944
|
+
throw new Error(`Button action "${raw}" needs ${add[1]} to be a :state list (declare it "${add[1]} = []") in ${ctx.file}.`);
|
|
945
|
+
}
|
|
946
|
+
return { op: "append-row", target };
|
|
947
|
+
}
|
|
938
948
|
const target = resolveTarget(add[1]);
|
|
939
|
-
const value = parseActionLiteral(
|
|
949
|
+
const value = parseActionLiteral(rhs);
|
|
940
950
|
if (Array.isArray(ctx.comp.state.get(target))) return { op: "append", target, value };
|
|
941
951
|
if (typeof value === "number") return { op: "add", target, value };
|
|
942
952
|
throw new Error(`Unsupported button action "${raw}". += with non-number values requires a list state target.`);
|
|
943
953
|
}
|
|
954
|
+
// Per-row: remove the current row from the list being looped — `todos remove todo`.
|
|
955
|
+
const remove = expression.match(/^([A-Za-z_$][\w$]*)\s+remove\s+([A-Za-z_$][\w$]*)$/);
|
|
956
|
+
if (remove) {
|
|
957
|
+
if (!ctx.loopItem) {
|
|
958
|
+
throw new Error(`Button action "${raw}" is only valid inside a reactive @loop in ${ctx.file}. Use: :button "Remove" -> list remove item`);
|
|
959
|
+
}
|
|
960
|
+
if (remove[2] !== ctx.loopItem) {
|
|
961
|
+
throw new Error(`Button action "${raw}" must remove the loop item "${ctx.loopItem}" in ${ctx.file}, not "${remove[2]}".`);
|
|
962
|
+
}
|
|
963
|
+
if (!ctx.loopKey || resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
|
|
964
|
+
throw new Error(`Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}.`);
|
|
965
|
+
}
|
|
966
|
+
return { op: "remove", target: ctx.loopKey };
|
|
967
|
+
}
|
|
944
968
|
const assign = expression.match(/^([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
|
|
945
969
|
if (assign) return { op: "set", target: resolveTarget(assign[1]), value: parseActionLiteral(assign[2]) };
|
|
946
|
-
throw new Error(`Unsupported button action "${raw}". Supported actions: count++, count--, count += 1, items += "value", name = "value".`);
|
|
970
|
+
throw new Error(`Unsupported button action "${raw}". Supported actions: count++, count--, count += 1, items += "value", name = "value", list remove item (in a loop), cart += item (in a loop).`);
|
|
947
971
|
}
|
|
948
972
|
|
|
949
973
|
function parseActionLiteral(raw) {
|
package/src/runtime.js
CHANGED
|
@@ -132,6 +132,7 @@ function render() {
|
|
|
132
132
|
}
|
|
133
133
|
used.add(key);
|
|
134
134
|
fillItem(node, item);
|
|
135
|
+
node.__wdItem = item; // let per-row actions resolve which row was clicked
|
|
135
136
|
out.appendChild(node);
|
|
136
137
|
}
|
|
137
138
|
for (const [key, node] of existing) {
|
|
@@ -156,6 +157,14 @@ document.addEventListener("input", (event) => {
|
|
|
156
157
|
render();
|
|
157
158
|
});
|
|
158
159
|
|
|
160
|
+
// The clicked button's row: the nearest reconciled loop node carries its item.
|
|
161
|
+
function clickedRow(el) {
|
|
162
|
+
const row = el.closest("[data-wd-loop-key]");
|
|
163
|
+
const region = el.closest("[data-wd-loop]");
|
|
164
|
+
if (!row || !region) return null;
|
|
165
|
+
return { srcKey: region.getAttribute("data-wd-loop"), item: row.__wdItem };
|
|
166
|
+
}
|
|
167
|
+
|
|
159
168
|
document.addEventListener("click", (event) => {
|
|
160
169
|
const action = event.target.closest("[data-wd-action]");
|
|
161
170
|
if (!action) return;
|
|
@@ -169,6 +178,20 @@ document.addEventListener("click", (event) => {
|
|
|
169
178
|
if (op === "add") state[target] = Number(state[target] ?? 0) + Number(value);
|
|
170
179
|
if (op === "append") state[target] = [...(Array.isArray(state[target]) ? state[target] : []), value];
|
|
171
180
|
if (op === "set") state[target] = value;
|
|
181
|
+
if (op === "remove") {
|
|
182
|
+
const row = clickedRow(action);
|
|
183
|
+
if (row && row.srcKey) state[row.srcKey] = (Array.isArray(state[row.srcKey]) ? state[row.srcKey] : []).filter((x) => x !== row.item);
|
|
184
|
+
}
|
|
185
|
+
if (op === "append-row") {
|
|
186
|
+
const row = clickedRow(action);
|
|
187
|
+
// Clone the row so each appended line is a distinct object — otherwise adding
|
|
188
|
+
// the same source row twice yields two identical references and a later
|
|
189
|
+
// remove (filter by !== ref) would delete both lines.
|
|
190
|
+
if (row && row.item !== undefined) {
|
|
191
|
+
const copy = row.item && typeof row.item === "object" ? structuredClone(row.item) : row.item;
|
|
192
|
+
state[target] = [...(Array.isArray(state[target]) ? state[target] : []), copy];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
172
195
|
savePersisted();
|
|
173
196
|
render();
|
|
174
197
|
});
|