@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 +307 -0
- package/CHANGELOG.md +16 -0
- package/README.md +55 -0
- package/docs/spec-alignment.md +10 -0
- package/package.json +2 -1
- package/site/pages/docs/index.wd +42 -0
- package/site/pages/reactive.skin +30 -0
- package/site/pages/reactive.wd +30 -0
- package/src/compiler.js +146 -11
- package/src/runtime.js +57 -2
- package/src/scaffold.js +42 -1
- package/src/skin.js +28 -5
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
|
package/docs/spec-alignment.md
CHANGED
|
@@ -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.
|
|
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"
|
package/site/pages/docs/index.wd
CHANGED
|
@@ -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 }`.
|
package/site/pages/reactive.skin
CHANGED
|
@@ -60,3 +60,33 @@ ul
|
|
|
60
60
|
|
|
61
61
|
.tally-card h3
|
|
62
62
|
color $warm
|
|
63
|
+
|
|
64
|
+
.card.product
|
|
65
|
+
display flex
|
|
66
|
+
align-items center
|
|
67
|
+
justify-content space-between
|
|
68
|
+
gap 1rem
|
|
69
|
+
border 1px solid $line
|
|
70
|
+
bg $panel
|
|
71
|
+
padding .6rem 1rem
|
|
72
|
+
margin .5rem 0
|
|
73
|
+
radius $radius
|
|
74
|
+
|
|
75
|
+
.card.product p
|
|
76
|
+
margin 0
|
|
77
|
+
|
|
78
|
+
.card.line
|
|
79
|
+
display flex
|
|
80
|
+
align-items center
|
|
81
|
+
justify-content space-between
|
|
82
|
+
gap 1rem
|
|
83
|
+
border 1px solid $line
|
|
84
|
+
padding .5rem 1rem
|
|
85
|
+
margin .4rem 0
|
|
86
|
+
radius $radius
|
|
87
|
+
|
|
88
|
+
.card.line button
|
|
89
|
+
bg transparent
|
|
90
|
+
color $warm
|
|
91
|
+
border 1px solid $line
|
|
92
|
+
padding .35rem .8rem
|
package/site/pages/reactive.wd
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
" <
|
|
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) =>
|
|
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
|
-
|
|
43
|
-
|
|
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 =
|
|
71
|
+
const cssProp = resolveProp(prop, rest);
|
|
50
72
|
const value = rest.join(" ").replace(/\$([a-zA-Z0-9_-]+)/g, "var(--$1)");
|
|
51
|
-
|
|
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}`);
|