@zvndev/darkmown 0.5.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 ADDED
@@ -0,0 +1,307 @@
1
+ # Building with Darkmown — agent guide
2
+
3
+ Darkmown is a Markdown-native web framework. You build sites by writing `.md` and `.wd` files. This sheet is everything you need. Read it fully before writing files, and **always ship styling** — an unstyled page is a failed page.
4
+
5
+ ## The one rule
6
+
7
+ - `.md` files are plain CommonMark. Directives stay inert text.
8
+ - `.wd` files are the same Markdown **plus directives** (state, loops, conditionals, includes, forms).
9
+ - To make a `.md` page interactive, **rename it to `.wd`**. ⚠️ Renaming means the old `.md` is **gone** — never leave both `index.md` and `index.wd`, that is a fatal `Duplicate route` build error. Upgrade = delete the `.md`, create the `.wd`.
10
+ - Static pages ship **zero** JavaScript; a page loads the ~2 KB runtime only if it declares reactive behavior.
11
+
12
+ ## Project layout & routing
13
+
14
+ ```
15
+ site/
16
+ pages/ ← the route tree. index.wd → /, about.wd → /about/, docs/index.wd → /docs/
17
+ _/ ← include shelf + shared partials (not routed). nav.wd and shelf JSON live here.
18
+ package.json
19
+ ```
20
+ - **Folders are routes.** `site/pages/blog/post.wd` serves at `/blog/post/`.
21
+ - Files/folders starting with `.`, `-`, or `_` are **hidden** from routing (drafts, partials).
22
+ - A `page.skin` next to `page.wd` attaches styling; a `page.js` attaches behavior. Matched by basename.
23
+
24
+ Commands: `darkmown dev`, `darkmown build`, `darkmown serve`.
25
+
26
+ ## Interpolation — one syntax
27
+
28
+ `{ name }` or `{ name.path }`. Resolves in order: loop item → in-scope value → declared `:state` → otherwise literal text. **Declare state before you bind it** — compilation is line-based, top to bottom, so a `{ x }` or `:if x` must come *after* the `:state x` (or the `:form into x`) that creates it.
29
+
30
+ ## Directives — the complete set (nothing else exists)
31
+
32
+ ### State and buttons
33
+ ```wd
34
+ :state count = 0
35
+ :state cart = [] persist ← persist = localStorage
36
+
37
+ Count: { count }
38
+
39
+ :button "Add" -> count++
40
+ :button "Add 5" -> count += 5
41
+ :button "Add item" -> cart += {"id": 1, "name": "Sticker"}
42
+ :button "Reset" -> count = 0
43
+ ```
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.
55
+
56
+ ### Conditionals
57
+ ```wd
58
+ :if count
59
+ Shown when truthy.
60
+ :else
61
+ Shown when falsy.
62
+ :endif
63
+ ```
64
+ `:if name.path` tests a dotted path. Conditionals nest, including per-row inside a loop.
65
+
66
+ ### Loops — the only loop
67
+ ```wd
68
+ @loop /products.json into product ← JSON file: unrolled at build time
69
+ - **{ product.name }** — { product.price }
70
+ @endloop
71
+
72
+ :state todos = [{"id": 1, "title": "Ship"}]
73
+ @loop todos into todo ← :state list: reactive, patched by key
74
+ - { todo.title }
75
+ @endloop
76
+ ```
77
+ Source: a JSON file path, an in-scope value, or a `:state` list. Loops nest; `:if todo.done` branches per row.
78
+
79
+ ### Filtering — `@loop … where` + live search with `:bind`
80
+ ```wd
81
+ @loop /products.json into p where p.featured == true and p.price < 80
82
+ - { p.name } ← item-only predicate → filtered at BUILD time, stays zero-JS
83
+ @endloop
84
+
85
+ :state products = [{"id":1,"name":"Aurora"},{"id":2,"name":"Briza"}]
86
+ :state q = ""
87
+ :bind q placeholder="Search" ← <input> bound two-way to :state q
88
+ @loop products into p where p.name contains q ← predicate reads :state → reactive, re-filters live
89
+ - { p.name }
90
+ @endloop
91
+ ```
92
+ `where` joins conditions with `and` / `or`. Operators: `==` `!=` `<` `<=` `>` `>=` and `contains` (case-insensitive substring). Operands are item paths (`p.field`), declared `:state`, numbers, or `"strings"` — whitelist-validated, no expressions. **Rule that decides JS: if the predicate only reads the row, it filters at build time and the page stays static; if it reads any `:state`, the loop becomes reactive.** `:bind name` is the input primitive for live filtering — it accepts `type=` (default `text`), `placeholder=`, `autocomplete=`, and `required` / `autofocus`.
93
+
94
+ ### Includes & sections
95
+ ```wd
96
+ @include /nav.wd ← partial from site/_/
97
+ @include /card.wd with title={ row.name }
98
+
99
+ ::: section #cart .panel ← container with id/classes; state inside is scoped to it
100
+ :state items = []
101
+ { items.length } items
102
+ :::
103
+ ```
104
+
105
+ ### Data, forms, computed
106
+ ```wd
107
+ :fetch team from "/__wd/data/team.json" ← loads JSON into state; sets team_error on failure
108
+ :fetch quotes from "/q.json" when=visible ← lazy, on scroll into view
109
+
110
+ :form into profile ← captures submit into state (declares `profile`), no backend
111
+ :input name placeholder="Your name" required
112
+ :input email type=email placeholder="Email" ← set type= for email/number/etc (default is text)
113
+ :submit "Save"
114
+ :endform
115
+
116
+ :if profile ← read form state AFTER :endform (see rule below)
117
+ Thanks, **{ profile.name }**!
118
+ :endif
119
+
120
+ :computed total = items.length * 4 ← derived state; arithmetic & comparisons only
121
+ ```
122
+ Shelf `.json` files (in `site/_/`) publish to `/__wd/data/`. `:form action="/api/x" into reply` posts urlencoded and lands the JSON reply in state (without JS it degrades to a native POST). Darkmown owns no backend — point forms at your own API.
123
+
124
+ **Form-state rules (these cause most form failures):**
125
+ - `:form into x` declares `x` only at `:endform`. A `:if x` (or `{ x.field }`) that reads it must come **after `:endform`**, never inside the form body.
126
+ - State declared inside a `::: section` is **scoped to that section** (as `sectionId:x`). If you wrap a `:form into x` in a section, a `:if x` *outside* that section can't see it. Keep the form and the `:if` that reads it in the **same scope** — or don't wrap the form in a section.
127
+
128
+ ## What HTML each directive emits — target these in `.skin`
129
+
130
+ Your `.skin` selectors must match the **real** output. The emitted HTML is:
131
+
132
+ | You write | It renders as |
133
+ |---|---|
134
+ | `# Heading`, prose, `- list` | normal `<h1>`, `<p>`, `<ul><li>` (style by tag) |
135
+ | `::: section #id .card … :::` | `<section id="id" class="card">…</section>` |
136
+ | `::: name .card … :::` (non-`section`) | `<div class="card">…</div>` |
137
+ | `:button "x" -> …` | `<button>x</button>` |
138
+ | `:input email …` | `<input type="…">` |
139
+ | `:bind q placeholder="…"` | `<input>` bound two-way to `:state q` |
140
+ | `:submit "Go"` | `<button type="submit">Go</button>` |
141
+ | `:form …` | `<form>…</form>` |
142
+ | reactive `@loop` of a list | `<ul>`/`<ol>` (or `<div>`) of items |
143
+ | `{ name }` | a text node |
144
+
145
+ So style with real selectors: `section`, `.card`, `button`, `input`, `form`, `h1`. **To add a class to a container, use `::: name .class`. For any other custom element, write raw HTML `<div class="…">`. There is NO `{.class}` attribute syntax.**
146
+
147
+ ## Styling with `.skin` — ship this on every page
148
+
149
+ A colocated `.skin` compiles to CSS. It is indentation-structural: a line is a **selector** if the next line is indented under it; otherwise it is a `property value` declaration. Start from a real design system — tokens, a type scale, spacing, a responsive grid:
150
+
151
+ ```skin
152
+ tokens ← becomes :root CSS variables ($name → var(--name))
153
+ ink #14181f
154
+ muted #5b6470
155
+ paper #ffffff
156
+ bg #f6f7f9
157
+ accent #4f46e5
158
+ line #e6e8ec
159
+ radius 14px
160
+
161
+ body
162
+ margin 0
163
+ font 16px/1.6 ui-sans-serif, system-ui, -apple-system, sans-serif
164
+ color $ink
165
+ background $bg
166
+
167
+ main
168
+ max-width 960px
169
+ margin 0 auto
170
+ padding 4rem 1.5rem
171
+
172
+ h1
173
+ font-size 3rem
174
+ line-height 1.05
175
+ letter-spacing -0.03em
176
+ margin 0 0 1rem
177
+
178
+ .hero
179
+ padding 5rem 2rem
180
+ border-radius $radius
181
+ background linear-gradient(135deg, #4f46e5, #9333ea)
182
+ color white
183
+
184
+ .grid
185
+ display grid
186
+ grid-template-columns repeat(3, 1fr)
187
+ gap 1.25rem
188
+
189
+ .card
190
+ background $paper
191
+ border 1px solid $line
192
+ border-radius $radius
193
+ padding 1.5rem
194
+
195
+ .actions
196
+ display flex
197
+ gap .75rem
198
+ flex-wrap wrap
199
+
200
+ button
201
+ font-size 1rem
202
+ padding .7rem 1.2rem
203
+ border 0
204
+ border-radius 10px
205
+ background $accent
206
+ color white
207
+ cursor pointer
208
+ transition transform .15s ease, box-shadow .15s ease
209
+
210
+ button:hover
211
+ transform translateY(-1px)
212
+ box-shadow 0 6px 20px rgba(79,70,229,.3)
213
+
214
+ input
215
+ width 100%
216
+ padding .7rem .9rem
217
+ border 1px solid $line
218
+ border-radius 10px
219
+ font-size 1rem
220
+
221
+ input:focus-visible
222
+ outline 2px solid $accent
223
+ outline-offset 2px
224
+
225
+ @media (max-width: 640px)
226
+ .grid
227
+ grid-template-columns 1fr
228
+ h1
229
+ font-size 2.1rem
230
+ ```
231
+ Always include: a tokens block, a real type scale, generous spacing, a `max-width` centered `main`, styled `button`/`input` with `:hover`/`:focus-visible` states, and a responsive `@media` rule (`.skin` supports `@media` — its indented rules are wrapped automatically). The `font` property is the CSS shorthand when it leads with a size (`font 16px/1.6 system-ui`) and `font-family` otherwise. Prefer `.skin`; a plain `<style>` block in a `.wd` also works.
232
+
233
+ **Layout gotcha — CTAs and link rows.** Markdown wraps adjacent links/buttons on consecutive lines into a single `<p>`, so putting `display:flex` on their container won't space them. To lay out a row of buttons or nav links, wrap them in explicit raw HTML and style that:
234
+ ```wd
235
+ <div class="actions">
236
+ <a class="btn" href="/signup">Start free</a>
237
+ <a class="btn ghost" href="/docs">Docs</a>
238
+ </div>
239
+ ```
240
+
241
+ ### Page structure & hierarchy — required for styles to apply
242
+ - **Wrap every page's body in `<main>`.** Your `main { … }` rules (centering, `max-width`, page padding) are dead unless the element exists.
243
+ - **One `#` per page** (the H1/title). Use `##` for section headings (they render as `<h2>` — style those). Never emit two `<h1>`s.
244
+ - **Only style classes you actually attach.** Add a class with `::: name .card` (container) or raw HTML `class="…"`. Don't write `.thanks { … }` then forget to put `.thanks` on the element — the styling silently does nothing. (Wrap a styled confirmation in `::: section .thanks … :::`.)
245
+ - **Make cards feel real:** a subtle `box-shadow`, a `:hover` lift, and a distinct price/secondary-text color or weight. Avoid emoji as icons — they read as a default-AI tell; prefer an inline SVG or a styled monogram.
246
+ - **Markdown is NOT parsed inside raw HTML blocks.** `**bold**` or `_em_` written inside a `<div>…</div>` renders as literal asterisks. Inside raw HTML use real tags (`<strong>`, `<em>`); use Markdown `**` only in plain prose lines.
247
+
248
+ ## The escape hatch — for logic directives can't express
249
+
250
+ Directives are narrow on purpose. For real logic (filtering, custom interactions), use a colocated `.js` with `window.wd` (`wd.get`, `wd.set`, `wd.state`, `wd.render`). Section state is keyed `sectionId:name`.
251
+
252
+ **Canonical "filter a list" pattern** (filtering is not a directive). Keep the fetched/source list intact and filter the rendered DOM — do not overwrite the source state, do not poll with setInterval:
253
+ ```wd
254
+ <input id="q" placeholder="Search" autocomplete="off">
255
+
256
+ :fetch products from "/__wd/data/products.json"
257
+ :if products
258
+ @loop products into p
259
+ ::: section .card
260
+ **{ p.name }** — { p.price }
261
+ :::
262
+ @endloop
263
+ :endif
264
+ ```
265
+ ```js
266
+ // products.js
267
+ const q = document.getElementById("q");
268
+ q.addEventListener("input", () => {
269
+ const term = q.value.toLowerCase();
270
+ for (const card of document.querySelectorAll(".card")) {
271
+ card.style.display = card.textContent.toLowerCase().includes(term) ? "" : "none";
272
+ }
273
+ });
274
+ ```
275
+ This visual DOM filter is fine for a static fetched list. (Note: a `wd.render()` triggered by other state changes can reset the toggled `display`; for a list that also mutates reactively, re-apply the filter after such changes.)
276
+
277
+ ## Hard rules — do not break these
278
+
279
+ 1. **Never invent directives or syntax.** These do NOT exist: `@section`/`@endsection`, `{% if %}`, `{.class}` attribute lists, `:for`, `v-if`, JSX. The only container is `::: … :::`; the only loop is `@loop`; the only interpolation is `{ }`.
280
+ 2. **Always ship styling.** A `.skin` (or `<style>`) with a modern type scale, spacing, and a responsive rule — every page. Unstyled = failure.
281
+ 3. **`.skin` must target emitted HTML** (see the table). Verify selectors match real elements.
282
+ 4. **Rename = delete.** Upgrading `.md`→`.wd` means removing the `.md`. Never leave both.
283
+ 5. **Declare before you bind.** `:state` (or `:form into x`) must appear before any `{ x }` / `:if x` that reads it.
284
+ 6. **Whitelisted grammars only.** Button/`:computed` actions are fixed forms; arbitrary logic goes in a `.js` escape hatch.
285
+
286
+ ## Minimal complete page
287
+
288
+ ```wd
289
+ ---
290
+ title: Hello
291
+ ---
292
+
293
+ @include /nav.wd
294
+
295
+ <main class="hero">
296
+
297
+ # Hello
298
+
299
+ :state count = 0
300
+
301
+ You clicked { count } times.
302
+
303
+ :button "Click" -> count++
304
+
305
+ </main>
306
+ ```
307
+ …with a colocated `index.skin` styling `.hero`, `button`, and the type scale.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
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
+
13
+ ## 0.6.0 — 2026-06-13
14
+
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.
16
+ - **`:bind <state>`** — a two-way `<input>` bound to a `:state` value, the input primitive behind live search. Accepts `type=` (default `text`), `placeholder=`, `autocomplete=`, and the `required` / `autofocus` flags. The field reflects state changes back unless it is focused.
17
+ - **`.skin` robustness:** `/* … */` block comments and decorative divider lines (`----`, `* * *`) are skipped instead of mis-parsed as rules.
18
+ - Scaffold (`darkmown init`): ships a styled default nav (brand + links, hover states) and an `about.md` companion page so a fresh project looks finished and the nav links resolve.
19
+ - Live demo: the reactive page now includes a real "search in pure Markdown" section powered by `@loop … where` + `:bind`.
20
+
5
21
  ## 0.5.0 — 2026-06-12
6
22
 
7
23
  - **Nested `:if` over loop items.** Conditionals inside a reactive `@loop` now nest — an inner `:if` resolves after the outer branch, per row, both at build time (balanced-region pre-render) and at runtime (recursive fill). Previously the compiler threw "not supported yet"; the loop-template fill no longer relies on a non-greedy regex that broke on nesting.
package/README.md CHANGED
@@ -74,6 +74,61 @@ One syntax everywhere: `{ name }` or `{ name.path }`.
74
74
 
75
75
  Loops nest, dotted paths reach into rows, and `@include ... with x={ row.field }` reassigns values Liquid-style.
76
76
 
77
+ ### Filtering — `@loop … where`
78
+
79
+ Add `where <predicate>` to filter a loop. Conditions compare a loop-item field against a number, a string, or another value, and join with `and` / `or`:
80
+
81
+ ```wd
82
+ @loop /products.json into p where p.featured == true and p.price < 80
83
+ - { p.name }
84
+ @endloop
85
+ ```
86
+
87
+ Operators: `==` `!=` `<` `<=` `>` `>=`, plus `contains` for case-insensitive substring match. The predicate is a compile-time-validated whitelist — only item paths, declared `:state`, numbers, and `"strings"` are allowed (no arbitrary expressions, no `eval`).
88
+
89
+ **The source decides reactivity, just like the loop itself.** If the predicate only reads the row, the filter runs at build time and the page stays **zero-JS**. If the predicate reads a `:state` value, the loop becomes reactive and re-filters live as that state changes — a live search in pure Markdown:
90
+
91
+ ```wd
92
+ :state products = [{"id":1,"name":"Aurora Lamp"},{"id":2,"name":"Briza Fan"}]
93
+ :state q = ""
94
+
95
+ :bind q placeholder="Search"
96
+
97
+ @loop products into p where p.name contains q
98
+ - { p.name }
99
+ @endloop
100
+ ```
101
+
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
+
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
+
77
132
  ## Sections
78
133
 
79
134
  ```wd
@@ -46,6 +46,16 @@ This audit compares the current Darkmown implementation with the original MARROW
46
46
  - **`darkmown serve`:** local preview of the built `dist`, no dev client injected.
47
47
  - **Packaging:** v0.3.0, license UNLICENSED pending a licensing decision, scaffold pins the live version, npm pack contents covered by tests.
48
48
 
49
+ ## Stage 6 additions (2026-06-13)
50
+
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
+ - **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
+ - **`: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`.
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.
56
+ - **Editor tooling:** VS Code extension (`editors/vscode`) ships `.wd` + `.skin` TextMate grammars, snippets, and folding, grammar-tested in CI.
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.
58
+
49
59
  ## Still missing from the full vision
50
60
 
51
61
  - First-party server runtime (`site/api/`), HTML-fragment `swap` semantics, and cart server sync — parked behind the adapter decision until a real project needs them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -19,6 +19,7 @@
19
19
  "src",
20
20
  "site",
21
21
  "docs",
22
+ "AGENTS.md",
22
23
  "README.md",
23
24
  "LICENSE",
24
25
  "CHANGELOG.md"
@@ -61,6 +61,48 @@ The source decides how the loop behaves:
61
61
 
62
62
  Loops nest, and `{ card.title }` style dotted paths reach into each row.
63
63
 
64
+ ### Filtering with `where`
65
+
66
+ Add `where <predicate>` to filter a loop. Conditions join with `and` / `or`:
67
+
68
+ ```wd
69
+ @loop /products.json into p where p.featured == true and p.price < 80
70
+ - { p.name }
71
+ @endloop
72
+ ```
73
+
74
+ Operators are `==` `!=` `<` `<=` `>` `>=` and `contains` (case-insensitive substring). Operands are item fields, declared `:state`, numbers, or `"strings"` — a validated whitelist, never arbitrary code.
75
+
76
+ The predicate decides reactivity exactly like the source does: read only the row and the filter runs at build time (zero JS); read a `:state` value and the loop re-filters live. Pair it with `:bind` for a search box in pure Markdown:
77
+
78
+ ```wd
79
+ :state products = [{"id":1,"name":"Aurora Lamp"},{"id":2,"name":"Briza Fan"}]
80
+ :state q = ""
81
+
82
+ :bind q placeholder="Search"
83
+
84
+ @loop products into p where p.name contains q
85
+ - { p.name }
86
+ @endloop
87
+ ```
88
+
89
+ `:bind <state>` renders an `<input>` wired two-way to a `:state` value — type and the list filters as you go.
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
+
64
106
  ## Interpolation
65
107
 
66
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
@@ -64,6 +64,36 @@ in progress …
64
64
 
65
65
  Each row picks one of three states from two nested conditionals, all in plain Markdown — no component, no ternary, no JavaScript.
66
66
 
67
+ ## Live filtering — search in pure Markdown
68
+
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
+
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 = []
73
+ :state q = ""
74
+
75
+ :bind q placeholder="Search products…"
76
+
77
+ @loop products into p where p.name contains q
78
+ ::: card .product
79
+ **{ p.name }** — ${ p.price }
80
+ :button "Add to cart" -> cart += p
81
+ :::
82
+ @endloop
83
+
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.
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
+
67
97
  ## Section-scoped state
68
98
 
69
99
  Two sections can each own a state value named `tally` without colliding. Buttons resolve to the nearest scope:
package/src/compiler.js CHANGED
@@ -178,6 +178,11 @@ function compileBody(lines, ctx) {
178
178
  out.push(handleInput(line, ctx));
179
179
  continue;
180
180
  }
181
+ if (/^:bind\s/.test(line)) {
182
+ flush();
183
+ out.push(handleBind(line, ctx));
184
+ continue;
185
+ }
181
186
  if (/^:submit\s/.test(line)) {
182
187
  flush();
183
188
  out.push(handleSubmit(line, ctx));
@@ -508,6 +513,38 @@ function handleInput(line, ctx) {
508
513
  return `<input type="${escapeHtml(type)}" ${attrs.join(" ")}>`;
509
514
  }
510
515
 
516
+ // :bind <state> [placeholder="…"] [type=…] — a live two-way input bound to a
517
+ // declared :state. Updates state on every keystroke; reflects state back when
518
+ // not focused. The state must be declared first (with :state).
519
+ function handleBind(line, ctx) {
520
+ const match = line.match(/^:bind\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
521
+ if (!match) throw new Error(`Malformed :bind in ${ctx.file}: ${line}. Use: :bind query placeholder="Search"`);
522
+ const key = resolveStateKey(match[1], ctx);
523
+ if (!key) {
524
+ throw new Error(`:bind ${match[1]} in ${ctx.file} has no matching state. Declare it first: :state ${match[1]} = ""`);
525
+ }
526
+ ctx.comp.assets.runtime = true;
527
+ let type = "text";
528
+ const attrs = [];
529
+ const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
530
+ for (const token of (match[2] || "").matchAll(re)) {
531
+ if (token[3]) {
532
+ if (!["required", "autofocus"].includes(token[3])) throw new Error(`Unknown :bind flag "${token[3]}" in ${ctx.file}`);
533
+ attrs.push(token[3]);
534
+ continue;
535
+ }
536
+ const value = stripQuotes(token[2]);
537
+ if (token[1] === "type") { type = value; continue; }
538
+ if (!["placeholder", "autocomplete"].includes(token[1])) {
539
+ throw new Error(`Unknown :bind attribute "${token[1]}" in ${ctx.file}`);
540
+ }
541
+ attrs.push(`${token[1]}="${escapeHtml(value)}"`);
542
+ }
543
+ const initial = ctx.comp.state.get(key);
544
+ const valueAttr = initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
545
+ return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
546
+ }
547
+
511
548
  function handleSubmit(line, ctx) {
512
549
  const match = line.match(/^:submit\s+"([^"]+)"\s*$/);
513
550
  if (!match) throw new Error(`Malformed :submit in ${ctx.file}: ${line}. Use: :submit "Label"`);
@@ -558,16 +595,17 @@ function handleIf(line, truthyLines, falsyLines, ctx) {
558
595
  }
559
596
 
560
597
  function handleLoop(line, bodyLines, ctx) {
561
- const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)\s*$/);
562
- if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. Use: @loop <things> into <thing>`);
598
+ const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(?:\s+where\s+(.+?))?\s*$/);
599
+ if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. Use: @loop <things> into <thing> [where <predicate>]`);
563
600
  const source = stripQuotes(match[1].trim());
564
601
  const itemName = match[2];
602
+ const where = match[3] ? compilePredicate(match[3].trim(), itemName, ctx) : null;
565
603
 
566
604
  if (source.startsWith("/") || source.startsWith("./") || source.startsWith("../") || source.endsWith(".json")) {
567
605
  const dataFile = resolveInclude(source, ctx.file, ctx.context, true);
568
606
  const rows = JSON.parse(fs.readFileSync(dataFile, "utf8"));
569
607
  if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
570
- return staticUnroll(rows, itemName, bodyLines, ctx);
608
+ return loopOverData(rows, itemName, bodyLines, ctx, where);
571
609
  }
572
610
 
573
611
  const resolved = lookupPath(source, ctx);
@@ -575,12 +613,12 @@ function handleLoop(line, bodyLines, ctx) {
575
613
  if (!Array.isArray(resolved.value)) {
576
614
  throw new Error(`@loop ${source} in ${ctx.file} found an in-scope value, but it is not a list`);
577
615
  }
578
- return staticUnroll(resolved.value, itemName, bodyLines, ctx);
616
+ return loopOverData(resolved.value, itemName, bodyLines, ctx, where);
579
617
  }
580
618
 
581
619
  if (/^[A-Za-z_$][\w$]*$/.test(source)) {
582
620
  const key = resolveStateKey(source, ctx);
583
- if (key) return reactiveLoop(key, itemName, bodyLines, ctx);
621
+ if (key) return reactiveLoop(key, itemName, bodyLines, ctx, where ? { where } : null);
584
622
  }
585
623
 
586
624
  throw new Error(
@@ -588,6 +626,74 @@ function handleLoop(line, bodyLines, ctx) {
588
626
  );
589
627
  }
590
628
 
629
+ // A static source (JSON file / in-scope value). No `where`, or a `where` that
630
+ // only reads the loop item → filter at build time and stay zero-JS. A `where`
631
+ // that reads :state → becomes a reactive filtered loop with the rows baked in.
632
+ function loopOverData(rows, itemName, bodyLines, ctx, where) {
633
+ if (!where) return staticUnroll(rows, itemName, bodyLines, ctx);
634
+ if (!where.refsState) return staticUnroll(rows.filter((row) => evalPredicate(where.body, row, ctx)), itemName, bodyLines, ctx);
635
+ return reactiveLoop(null, itemName, bodyLines, ctx, { where, data: rows });
636
+ }
637
+
638
+ // Compile a `where` predicate to a safe JS boolean expression over I()/S()/C().
639
+ // Conditions (operand <op> operand) join with `and`/`or`; operands are loop-item
640
+ // paths, declared :state, numbers, or strings. No identifiers survive un-mapped.
641
+ function compilePredicate(raw, itemName, ctx) {
642
+ const parts = raw.split(/\s+(and|or)\s+/i);
643
+ const pieces = [];
644
+ let refsState = false;
645
+ for (let i = 0; i < parts.length; i++) {
646
+ if (i % 2 === 1) { pieces.push(parts[i].toLowerCase() === "and" ? "&&" : "||"); continue; }
647
+ const cond = compileCondition(parts[i].trim(), itemName, ctx);
648
+ refsState = refsState || cond.usesState;
649
+ pieces.push(`(${cond.expr})`);
650
+ }
651
+ return { body: pieces.join(" "), refsState };
652
+ }
653
+
654
+ function compileCondition(cond, itemName, ctx) {
655
+ const m = cond.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
656
+ if (!m) throw new Error(`Malformed where-condition "${cond}" in ${ctx.file}. Use: ${itemName}.field contains state, or ${itemName}.field <op> value.`);
657
+ const left = compileOperand(m[1].trim(), itemName, ctx);
658
+ const right = compileOperand(m[3].trim(), itemName, ctx);
659
+ const usesState = left.usesState || right.usesState;
660
+ const op = m[2].toLowerCase();
661
+ if (op === "contains") return { expr: `C(${left.code}, ${right.code})`, usesState };
662
+ return { expr: `${left.code} ${op} ${right.code}`, usesState };
663
+ }
664
+
665
+ function compileOperand(tok, itemName, ctx) {
666
+ if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok)) return { code: JSON.stringify(tok.slice(1, -1)), usesState: false };
667
+ if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, usesState: false };
668
+ if (["true", "false", "null"].includes(tok)) return { code: tok, usesState: false };
669
+ if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
670
+ throw new Error(`Unsupported operand "${tok}" in @loop where (${ctx.file}). Use ${itemName}.field, a :state name, a number, or a "string".`);
671
+ }
672
+ const segs = tok.split(".");
673
+ if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
674
+ throw new Error(`Path "${tok}" is not allowed in @loop where (${ctx.file})`);
675
+ }
676
+ if (segs[0] === itemName) return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, usesState: false };
677
+ const key = resolveStateKey(segs[0], ctx);
678
+ if (!key) {
679
+ throw new Error(`@loop where references unknown name "${segs[0]}" in ${ctx.file}. Use the loop item (${itemName}.field) or a declared :state.`);
680
+ }
681
+ const rest = segs.slice(1).join(".");
682
+ return { code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`, usesState: true };
683
+ }
684
+
685
+ const containsHelper = (a, b) => String(a ?? "").toLowerCase().includes(String(b ?? "").toLowerCase());
686
+
687
+ function evalPredicate(body, item, ctx) {
688
+ try {
689
+ const I = (p) => getPath(item, p ? p.split(".") : []);
690
+ const S = (k, r) => getPath(ctx.comp.state.get(k), r ? r.split(".") : []);
691
+ return Boolean(new Function("I", "S", "C", `return (${body});`)(I, S, containsHelper));
692
+ } catch {
693
+ return false;
694
+ }
695
+ }
696
+
591
697
  function staticUnroll(rows, itemName, bodyLines, ctx) {
592
698
  const out = [];
593
699
  for (const row of rows) {
@@ -597,9 +703,9 @@ function staticUnroll(rows, itemName, bodyLines, ctx) {
597
703
  return out.join("\n");
598
704
  }
599
705
 
600
- function reactiveLoop(key, itemName, bodyLines, ctx) {
706
+ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
601
707
  ctx.comp.assets.runtime = true;
602
- const templateCtx = { ...ctx, loopItem: itemName, scope: createScope(ctx.scope) };
708
+ const templateCtx = { ...ctx, loopItem: itemName, loopKey: key, scope: createScope(ctx.scope) };
603
709
  const templateHtml = compileBody(bodyLines, templateCtx).trim();
604
710
 
605
711
  let wrapperTag = "div";
@@ -612,7 +718,10 @@ function reactiveLoop(key, itemName, bodyLines, ctx) {
612
718
  itemTemplate = `<div data-wd-loop-piece>${templateHtml}</div>`;
613
719
  }
614
720
 
615
- const rows = Array.isArray(ctx.comp.state.get(key)) ? ctx.comp.state.get(key) : [];
721
+ const where = opts?.where || null;
722
+ const baked = opts?.data || null; // rows for a static source filtered by state
723
+ const allRows = key ? (Array.isArray(ctx.comp.state.get(key)) ? ctx.comp.state.get(key) : []) : (baked || []);
724
+ const rows = where ? allRows.filter((row) => evalPredicate(where.body, row, ctx)) : allRows;
616
725
  const counts = new Map();
617
726
  const initial = rows
618
727
  .map((item) => {
@@ -621,7 +730,9 @@ function reactiveLoop(key, itemName, bodyLines, ctx) {
621
730
  })
622
731
  .join("");
623
732
 
624
- return `<div data-wd-loop="${key}"><template data-wd-loop-template>${itemTemplate}</template><${wrapperTag} data-wd-loop-out>${initial}</${wrapperTag}></div>`;
733
+ const whereAttr = where ? ` data-wd-loop-where="${escapeHtml(where.body)}"` : "";
734
+ const dataAttr = baked ? ` data-wd-loop-data="${escapeHtml(JSON.stringify(baked))}"` : "";
735
+ return `<div data-wd-loop="${key || ""}"${whereAttr}${dataAttr}><template data-wd-loop-template>${itemTemplate}</template><${wrapperTag} data-wd-loop-out>${initial}</${wrapperTag}></div>`;
625
736
  }
626
737
 
627
738
  function withLoopKey(template, itemKey) {
@@ -822,17 +933,41 @@ function parseAction(raw, ctx) {
822
933
  if (increment) return { op: "inc", target: resolveTarget(increment[1]) };
823
934
  const decrement = expression.match(/^([A-Za-z_$][\w$]*)--$/);
824
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.
825
938
  const add = expression.match(/^([A-Za-z_$][\w$]*)\s*\+=\s*(.+)$/);
826
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
+ }
827
948
  const target = resolveTarget(add[1]);
828
- const value = parseActionLiteral(add[2]);
949
+ const value = parseActionLiteral(rhs);
829
950
  if (Array.isArray(ctx.comp.state.get(target))) return { op: "append", target, value };
830
951
  if (typeof value === "number") return { op: "add", target, value };
831
952
  throw new Error(`Unsupported button action "${raw}". += with non-number values requires a list state target.`);
832
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
+ }
833
968
  const assign = expression.match(/^([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
834
969
  if (assign) return { op: "set", target: resolveTarget(assign[1]), value: parseActionLiteral(assign[2]) };
835
- 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).`);
836
971
  }
837
972
 
838
973
  function parseActionLiteral(raw) {
package/src/runtime.js CHANGED
@@ -50,6 +50,17 @@ function recompute() {
50
50
  }
51
51
  }
52
52
 
53
+ const containsFn = (a, b) => String(a ?? "").toLowerCase().includes(String(b ?? "").toLowerCase());
54
+ const predicateCache = new Map();
55
+ function loopPredicate(body) {
56
+ let fn = predicateCache.get(body);
57
+ if (!fn) {
58
+ fn = new Function("I", "S", "C", `return (${body});`);
59
+ predicateCache.set(body, fn);
60
+ }
61
+ return fn;
62
+ }
63
+
53
64
  function loopKeyOf(item, counts) {
54
65
  const base =
55
66
  item && typeof item === "object"
@@ -91,11 +102,20 @@ function render() {
91
102
  }
92
103
 
93
104
  for (const region of document.querySelectorAll("[data-wd-loop]")) {
94
- const rows = state[region.getAttribute("data-wd-loop")];
105
+ const key = region.getAttribute("data-wd-loop");
106
+ const data = region.getAttribute("data-wd-loop-data");
107
+ const rows = key ? state[key] : (data ? JSON.parse(data) : []);
95
108
  const template = region.querySelector("template[data-wd-loop-template]");
96
109
  const out = region.querySelector("[data-wd-loop-out]");
97
110
  if (!template || !out) continue;
98
- const list = Array.isArray(rows) ? rows : [];
111
+ let list = Array.isArray(rows) ? rows : [];
112
+ const where = region.getAttribute("data-wd-loop-where");
113
+ if (where) {
114
+ const predicate = loopPredicate(where);
115
+ list = list.filter((item) =>
116
+ predicate((path) => getPath(item, path), (k, r) => getPath(state[k], r || ""), containsFn)
117
+ );
118
+ }
99
119
 
100
120
  const existing = new Map();
101
121
  for (const child of [...out.children]) {
@@ -112,6 +132,7 @@ function render() {
112
132
  }
113
133
  used.add(key);
114
134
  fillItem(node, item);
135
+ node.__wdItem = item; // let per-row actions resolve which row was clicked
115
136
  out.appendChild(node);
116
137
  }
117
138
  for (const [key, node] of existing) {
@@ -122,6 +143,26 @@ function render() {
122
143
  for (const node of document.querySelectorAll("[data-wd-bind]")) {
123
144
  node.textContent = getPath(state[node.getAttribute("data-wd-bind")], node.getAttribute("data-wd-path")) ?? "";
124
145
  }
146
+
147
+ for (const input of document.querySelectorAll("[data-wd-bind-input]")) {
148
+ if (document.activeElement !== input) input.value = state[input.getAttribute("data-wd-bind-input")] ?? "";
149
+ }
150
+ }
151
+
152
+ document.addEventListener("input", (event) => {
153
+ const input = event.target.closest("[data-wd-bind-input]");
154
+ if (!input) return;
155
+ state[input.getAttribute("data-wd-bind-input")] = input.value;
156
+ savePersisted();
157
+ render();
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 };
125
166
  }
126
167
 
127
168
  document.addEventListener("click", (event) => {
@@ -137,6 +178,20 @@ document.addEventListener("click", (event) => {
137
178
  if (op === "add") state[target] = Number(state[target] ?? 0) + Number(value);
138
179
  if (op === "append") state[target] = [...(Array.isArray(state[target]) ? state[target] : []), value];
139
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
+ }
140
195
  savePersisted();
141
196
  render();
142
197
  });
package/src/scaffold.js CHANGED
@@ -62,6 +62,31 @@ export function initProject(root) {
62
62
  " margin 0 auto",
63
63
  " padding 3rem 1.5rem",
64
64
  "",
65
+ "nav",
66
+ " display flex",
67
+ " align-items center",
68
+ " justify-content space-between",
69
+ " gap 1rem",
70
+ " max-width 760px",
71
+ " margin 0 auto",
72
+ " padding 1.25rem 1.5rem",
73
+ "",
74
+ "nav strong",
75
+ " font-size 1.1rem",
76
+ "",
77
+ "nav .links",
78
+ " display flex",
79
+ " gap 1.25rem",
80
+ "",
81
+ "nav a",
82
+ " color $ink",
83
+ " text-decoration none",
84
+ " opacity .8",
85
+ "",
86
+ "nav a:hover",
87
+ " opacity 1",
88
+ " color $accent",
89
+ "",
65
90
  "button",
66
91
  " bg $accent",
67
92
  " color white",
@@ -71,9 +96,25 @@ export function initProject(root) {
71
96
  ].join("\n"));
72
97
  writeNew(root, "site/_/nav.wd", [
73
98
  "<nav>",
74
- " <a href=\"/\">Home</a>",
99
+ " <strong>My site</strong>",
100
+ " <span class=\"links\">",
101
+ " <a href=\"/\">Home</a>",
102
+ " <a href=\"/about/\">About</a>",
103
+ " </span>",
75
104
  "</nav>"
76
105
  ].join("\n"));
106
+ writeNew(root, "site/pages/about.md", [
107
+ "---",
108
+ "title: About",
109
+ "---",
110
+ "",
111
+ "# About",
112
+ "",
113
+ "This page is plain Markdown (`.md`) — strict CommonMark, zero framework JavaScript.",
114
+ "Rename it to `.wd` when you want directives like state, loops, or forms.",
115
+ "",
116
+ "[Back home](/)"
117
+ ].join("\n"));
77
118
  writeNew(root, "README.md", [
78
119
  "# My Darkmown site",
79
120
  "",
package/src/skin.js CHANGED
@@ -5,11 +5,27 @@ const aliases = new Map([
5
5
  ["bg", "background"]
6
6
  ]);
7
7
 
8
+ // `font` is aliased to font-family for the common `font <stack>` case, but the
9
+ // real CSS `font` shorthand (`font 16px/1.4 system-ui`) must pass through. If
10
+ // the value looks like a shorthand (leads with a size or contains `/`), keep it.
11
+ function resolveProp(prop, rest) {
12
+ if (prop === "font" && (/^[\d.]/.test(rest[0] || "") || rest.join(" ").includes("/"))) return "font";
13
+ return aliases.get(prop) || prop;
14
+ }
15
+
8
16
  export function compileSkin(source) {
9
17
  const lines = source
10
18
  .replace(/\r\n?/g, "\n")
19
+ .replace(/\/\*[\s\S]*?\*\//g, "") // strip /* … */ block comments (any span)
11
20
  .split("\n")
12
- .filter((raw) => raw.trim() && !raw.trim().startsWith("//"))
21
+ .filter((raw) => {
22
+ const t = raw.trim();
23
+ if (!t || t.startsWith("//")) return false;
24
+ // Decorative divider lines (----, ====, * * *, ~~~) carry no rule and would
25
+ // otherwise parse as a bogus selector/declaration. Anything with a letter or
26
+ // digit is real content; punctuation-only lines are skipped.
27
+ return /[A-Za-z0-9]/.test(t);
28
+ })
13
29
  .map((raw) => ({ indent: raw.match(/^\s*/)[0].length, text: raw.trim() }));
14
30
 
15
31
  const stack = [];
@@ -39,16 +55,23 @@ export function compileSkin(source) {
39
55
 
40
56
  if (opensBlock) {
41
57
  const parent = stack.at(-1)?.selector;
42
- const selector = normalizeSelector(text, parent);
43
- stack.push({ indent, selector });
58
+ // At-rules (@media, @supports) wrap rules instead of extending selectors:
59
+ // carry the outer selector through so nested selectors combine normally.
60
+ if (text.startsWith("@")) {
61
+ stack.push({ indent, selector: parent ?? null, media: text });
62
+ } else {
63
+ stack.push({ indent, selector: normalizeSelector(text, parent) });
64
+ }
44
65
  continue;
45
66
  }
46
67
 
47
68
  const current = stack.at(-1)?.selector || ":root";
69
+ const media = stack.find((frame) => frame.media)?.media;
48
70
  const [prop, ...rest] = text.split(/\s+/);
49
- const cssProp = aliases.get(prop) || prop;
71
+ const cssProp = resolveProp(prop, rest);
50
72
  const value = rest.join(" ").replace(/\$([a-zA-Z0-9_-]+)/g, "var(--$1)");
51
- css.push(`${current} { ${cssProp}: ${value}; }`);
73
+ const rule = `${current} { ${cssProp}: ${value}; }`;
74
+ css.push(media ? `${media} { ${rule} }` : rule);
52
75
  }
53
76
 
54
77
  if (rootVars.length) css.unshift(`:root {\n${rootVars.join("\n")}\n}`);