@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 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`. Values are JSON literals — they cannot reference loop items or other state. **`:button` is for state actions only** — for a link styled as a button, write raw HTML `<a class="btn" href="…">`.
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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 }`.
@@ -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
@@ -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
- - **{ p.name }** — ${ p.price }
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(add[2]);
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
  });