@zvndev/darkmown 0.5.0 → 0.6.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,297 @@
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`. 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="…">`.
45
+
46
+ ### Conditionals
47
+ ```wd
48
+ :if count
49
+ Shown when truthy.
50
+ :else
51
+ Shown when falsy.
52
+ :endif
53
+ ```
54
+ `:if name.path` tests a dotted path. Conditionals nest, including per-row inside a loop.
55
+
56
+ ### Loops — the only loop
57
+ ```wd
58
+ @loop /products.json into product ← JSON file: unrolled at build time
59
+ - **{ product.name }** — { product.price }
60
+ @endloop
61
+
62
+ :state todos = [{"id": 1, "title": "Ship"}]
63
+ @loop todos into todo ← :state list: reactive, patched by key
64
+ - { todo.title }
65
+ @endloop
66
+ ```
67
+ Source: a JSON file path, an in-scope value, or a `:state` list. Loops nest; `:if todo.done` branches per row.
68
+
69
+ ### Filtering — `@loop … where` + live search with `:bind`
70
+ ```wd
71
+ @loop /products.json into p where p.featured == true and p.price < 80
72
+ - { p.name } ← item-only predicate → filtered at BUILD time, stays zero-JS
73
+ @endloop
74
+
75
+ :state products = [{"id":1,"name":"Aurora"},{"id":2,"name":"Briza"}]
76
+ :state q = ""
77
+ :bind q placeholder="Search" ← <input> bound two-way to :state q
78
+ @loop products into p where p.name contains q ← predicate reads :state → reactive, re-filters live
79
+ - { p.name }
80
+ @endloop
81
+ ```
82
+ `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`.
83
+
84
+ ### Includes & sections
85
+ ```wd
86
+ @include /nav.wd ← partial from site/_/
87
+ @include /card.wd with title={ row.name }
88
+
89
+ ::: section #cart .panel ← container with id/classes; state inside is scoped to it
90
+ :state items = []
91
+ { items.length } items
92
+ :::
93
+ ```
94
+
95
+ ### Data, forms, computed
96
+ ```wd
97
+ :fetch team from "/__wd/data/team.json" ← loads JSON into state; sets team_error on failure
98
+ :fetch quotes from "/q.json" when=visible ← lazy, on scroll into view
99
+
100
+ :form into profile ← captures submit into state (declares `profile`), no backend
101
+ :input name placeholder="Your name" required
102
+ :input email type=email placeholder="Email" ← set type= for email/number/etc (default is text)
103
+ :submit "Save"
104
+ :endform
105
+
106
+ :if profile ← read form state AFTER :endform (see rule below)
107
+ Thanks, **{ profile.name }**!
108
+ :endif
109
+
110
+ :computed total = items.length * 4 ← derived state; arithmetic & comparisons only
111
+ ```
112
+ 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.
113
+
114
+ **Form-state rules (these cause most form failures):**
115
+ - `: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.
116
+ - 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.
117
+
118
+ ## What HTML each directive emits — target these in `.skin`
119
+
120
+ Your `.skin` selectors must match the **real** output. The emitted HTML is:
121
+
122
+ | You write | It renders as |
123
+ |---|---|
124
+ | `# Heading`, prose, `- list` | normal `<h1>`, `<p>`, `<ul><li>` (style by tag) |
125
+ | `::: section #id .card … :::` | `<section id="id" class="card">…</section>` |
126
+ | `::: name .card … :::` (non-`section`) | `<div class="card">…</div>` |
127
+ | `:button "x" -> …` | `<button>x</button>` |
128
+ | `:input email …` | `<input type="…">` |
129
+ | `:bind q placeholder="…"` | `<input>` bound two-way to `:state q` |
130
+ | `:submit "Go"` | `<button type="submit">Go</button>` |
131
+ | `:form …` | `<form>…</form>` |
132
+ | reactive `@loop` of a list | `<ul>`/`<ol>` (or `<div>`) of items |
133
+ | `{ name }` | a text node |
134
+
135
+ 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.**
136
+
137
+ ## Styling with `.skin` — ship this on every page
138
+
139
+ 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:
140
+
141
+ ```skin
142
+ tokens ← becomes :root CSS variables ($name → var(--name))
143
+ ink #14181f
144
+ muted #5b6470
145
+ paper #ffffff
146
+ bg #f6f7f9
147
+ accent #4f46e5
148
+ line #e6e8ec
149
+ radius 14px
150
+
151
+ body
152
+ margin 0
153
+ font 16px/1.6 ui-sans-serif, system-ui, -apple-system, sans-serif
154
+ color $ink
155
+ background $bg
156
+
157
+ main
158
+ max-width 960px
159
+ margin 0 auto
160
+ padding 4rem 1.5rem
161
+
162
+ h1
163
+ font-size 3rem
164
+ line-height 1.05
165
+ letter-spacing -0.03em
166
+ margin 0 0 1rem
167
+
168
+ .hero
169
+ padding 5rem 2rem
170
+ border-radius $radius
171
+ background linear-gradient(135deg, #4f46e5, #9333ea)
172
+ color white
173
+
174
+ .grid
175
+ display grid
176
+ grid-template-columns repeat(3, 1fr)
177
+ gap 1.25rem
178
+
179
+ .card
180
+ background $paper
181
+ border 1px solid $line
182
+ border-radius $radius
183
+ padding 1.5rem
184
+
185
+ .actions
186
+ display flex
187
+ gap .75rem
188
+ flex-wrap wrap
189
+
190
+ button
191
+ font-size 1rem
192
+ padding .7rem 1.2rem
193
+ border 0
194
+ border-radius 10px
195
+ background $accent
196
+ color white
197
+ cursor pointer
198
+ transition transform .15s ease, box-shadow .15s ease
199
+
200
+ button:hover
201
+ transform translateY(-1px)
202
+ box-shadow 0 6px 20px rgba(79,70,229,.3)
203
+
204
+ input
205
+ width 100%
206
+ padding .7rem .9rem
207
+ border 1px solid $line
208
+ border-radius 10px
209
+ font-size 1rem
210
+
211
+ input:focus-visible
212
+ outline 2px solid $accent
213
+ outline-offset 2px
214
+
215
+ @media (max-width: 640px)
216
+ .grid
217
+ grid-template-columns 1fr
218
+ h1
219
+ font-size 2.1rem
220
+ ```
221
+ 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.
222
+
223
+ **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:
224
+ ```wd
225
+ <div class="actions">
226
+ <a class="btn" href="/signup">Start free</a>
227
+ <a class="btn ghost" href="/docs">Docs</a>
228
+ </div>
229
+ ```
230
+
231
+ ### Page structure & hierarchy — required for styles to apply
232
+ - **Wrap every page's body in `<main>`.** Your `main { … }` rules (centering, `max-width`, page padding) are dead unless the element exists.
233
+ - **One `#` per page** (the H1/title). Use `##` for section headings (they render as `<h2>` — style those). Never emit two `<h1>`s.
234
+ - **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 … :::`.)
235
+ - **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.
236
+ - **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.
237
+
238
+ ## The escape hatch — for logic directives can't express
239
+
240
+ 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`.
241
+
242
+ **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:
243
+ ```wd
244
+ <input id="q" placeholder="Search" autocomplete="off">
245
+
246
+ :fetch products from "/__wd/data/products.json"
247
+ :if products
248
+ @loop products into p
249
+ ::: section .card
250
+ **{ p.name }** — { p.price }
251
+ :::
252
+ @endloop
253
+ :endif
254
+ ```
255
+ ```js
256
+ // products.js
257
+ const q = document.getElementById("q");
258
+ q.addEventListener("input", () => {
259
+ const term = q.value.toLowerCase();
260
+ for (const card of document.querySelectorAll(".card")) {
261
+ card.style.display = card.textContent.toLowerCase().includes(term) ? "" : "none";
262
+ }
263
+ });
264
+ ```
265
+ 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.)
266
+
267
+ ## Hard rules — do not break these
268
+
269
+ 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 `{ }`.
270
+ 2. **Always ship styling.** A `.skin` (or `<style>`) with a modern type scale, spacing, and a responsive rule — every page. Unstyled = failure.
271
+ 3. **`.skin` must target emitted HTML** (see the table). Verify selectors match real elements.
272
+ 4. **Rename = delete.** Upgrading `.md`→`.wd` means removing the `.md`. Never leave both.
273
+ 5. **Declare before you bind.** `:state` (or `:form into x`) must appear before any `{ x }` / `:if x` that reads it.
274
+ 6. **Whitelisted grammars only.** Button/`:computed` actions are fixed forms; arbitrary logic goes in a `.js` escape hatch.
275
+
276
+ ## Minimal complete page
277
+
278
+ ```wd
279
+ ---
280
+ title: Hello
281
+ ---
282
+
283
+ @include /nav.wd
284
+
285
+ <main class="hero">
286
+
287
+ # Hello
288
+
289
+ :state count = 0
290
+
291
+ You clicked { count } times.
292
+
293
+ :button "Click" -> count++
294
+
295
+ </main>
296
+ ```
297
+ …with a colocated `index.skin` styling `.hero`, `button`, and the type scale.
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.6.0 — 2026-06-13
6
+
7
+ - **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.
8
+ - **`: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.
9
+ - **`.skin` robustness:** `/* … */` block comments and decorative divider lines (`----`, `* * *`) are skipped instead of mis-parsed as rules.
10
+ - 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.
11
+ - Live demo: the reactive page now includes a real "search in pure Markdown" section powered by `@loop … where` + `:bind`.
12
+
5
13
  ## 0.5.0 — 2026-06-12
6
14
 
7
15
  - **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,33 @@ 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
+
77
104
  ## Sections
78
105
 
79
106
  ```wd
@@ -46,6 +46,15 @@ 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
+ - **`.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
+ - **Editor tooling:** VS Code extension (`editors/vscode`) ships `.wd` + `.skin` TextMate grammars, snippets, and folding, grammar-tested in CI.
56
+ - **`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.
57
+
49
58
  ## Still missing from the full vision
50
59
 
51
60
  - 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.6.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,33 @@ 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
+
64
91
  ## Interpolation
65
92
 
66
93
  One syntax everywhere: `{ name }` or `{ name.path }`.
@@ -64,6 +64,21 @@ 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 q = ""
73
+
74
+ :bind q placeholder="Search products…"
75
+
76
+ @loop products into p where p.name contains q
77
+ - **{ p.name }** — ${ p.price }
78
+ @endloop
79
+
80
+ 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
+
67
82
  ## Section-scoped state
68
83
 
69
84
  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,7 +703,7 @@ 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
708
  const templateCtx = { ...ctx, loopItem: itemName, scope: createScope(ctx.scope) };
603
709
  const templateHtml = compileBody(bodyLines, templateCtx).trim();
@@ -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) {
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]) {
@@ -122,8 +142,20 @@ function render() {
122
142
  for (const node of document.querySelectorAll("[data-wd-bind]")) {
123
143
  node.textContent = getPath(state[node.getAttribute("data-wd-bind")], node.getAttribute("data-wd-path")) ?? "";
124
144
  }
145
+
146
+ for (const input of document.querySelectorAll("[data-wd-bind-input]")) {
147
+ if (document.activeElement !== input) input.value = state[input.getAttribute("data-wd-bind-input")] ?? "";
148
+ }
125
149
  }
126
150
 
151
+ document.addEventListener("input", (event) => {
152
+ const input = event.target.closest("[data-wd-bind-input]");
153
+ if (!input) return;
154
+ state[input.getAttribute("data-wd-bind-input")] = input.value;
155
+ savePersisted();
156
+ render();
157
+ });
158
+
127
159
  document.addEventListener("click", (event) => {
128
160
  const action = event.target.closest("[data-wd-action]");
129
161
  if (!action) return;
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}`);