@zvndev/darkmown 0.4.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 +297 -0
- package/CHANGELOG.md +15 -0
- package/README.md +34 -3
- package/docs/spec-alignment.md +11 -2
- package/package.json +3 -2
- package/site/_/nav.skin +2 -1
- package/site/_/nav.wd +1 -0
- package/site/_/roadmap.json +7 -0
- package/site/pages/app.skin +76 -0
- package/site/pages/app.wd +92 -0
- package/site/pages/docs/index.wd +30 -3
- package/site/pages/index.wd +30 -14
- package/site/pages/reactive.wd +40 -0
- package/src/compiler.js +191 -20
- package/src/runtime.js +37 -2
- package/src/scaffold.js +42 -1
- package/src/skin.js +28 -5
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,21 @@
|
|
|
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
|
+
|
|
13
|
+
## 0.5.0 — 2026-06-12
|
|
14
|
+
|
|
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.
|
|
16
|
+
- **VS Code extension** (`editors/vscode`): syntax highlighting, snippets, and folding for `.wd` and `.skin` files. Grammars are tested through the real VS Code tokenizer (vscode-textmate) in CI.
|
|
17
|
+
- New `/app/` demo — a whole live app in one `.wd` file: static build-time loop, fetched data looped with nested conditionals, a persistent counter with a computed milestone, and a form that round-trips to a real server. It is the framework's elevator pitch as a single page.
|
|
18
|
+
- Site: landing page repositioned around the core wedge ("Markdown that runs" — full reactive websites in plain Markdown). `description`/Open Graph/Twitter meta tags now emit from frontmatter. Runtime-size claims corrected to the true ~2 KB; mobile nav wraps instead of overflowing.
|
|
19
|
+
|
|
5
20
|
## 0.4.0 — 2026-06-11
|
|
6
21
|
|
|
7
22
|
- **Renamed**: the framework is now **Darkmown** (formerly Markie). Package `@zvndev/darkmown` (npm's typosquat guard reserves bare `darkmown` as too similar to `markdown`), CLI `darkmown`. The `.wd` format and `/__wd/` asset paths are unchanged.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**[darkmown.com](https://darkmown.com)** · markdown, rearranged.
|
|
4
4
|
|
|
5
|
-
Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime
|
|
5
|
+
Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around 2 KB gzipped (CI-enforced under 5 KB).
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
@@ -45,7 +45,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
|
|
|
45
45
|
- Files or folders starting with `.`, `-`, or `_` are hidden from routing.
|
|
46
46
|
- `site/_` is the include shelf for `@include /name.wd`.
|
|
47
47
|
- Matching `page.skin` and `page.js` colocate styling and behavior by basename.
|
|
48
|
-
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~
|
|
48
|
+
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~2 KB gzipped, CI-enforced under 5 KB).
|
|
49
49
|
- Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
|
|
50
50
|
|
|
51
51
|
## Interpolation
|
|
@@ -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
|
|
@@ -132,12 +159,16 @@ Loading…
|
|
|
132
159
|
- `:form into name` captures submits straight into state (no backend). `:form action="/url"` emits a plain native form instead — zero JS, full progressive enhancement.
|
|
133
160
|
- `:form action="/url" into reply` does both: with JS the submit posts urlencoded via fetch and the JSON reply lands in state `reply` (`reply_error` on failure); without JS it is the same native POST. Darkmown adapts to any backend — it does not own one. `darkmown dev` ships a `/__wd/echo` endpoint for demos.
|
|
134
161
|
- `:state x = [] persist` keeps that state in localStorage across reloads.
|
|
135
|
-
- `:if item.path` works inside reactive loops for per-row branches.
|
|
162
|
+
- `:if item.path` works inside reactive loops for per-row branches, and nests — an inner `:if` resolves after the outer branch and stays reactive.
|
|
136
163
|
|
|
137
164
|
## The escape hatch
|
|
138
165
|
|
|
139
166
|
Reactive pages expose `window.wd` — `wd.get(key)`, `wd.set(key, value)`, `wd.state`, `wd.render()` — so colocated `.js` can do anything the directives can't. Section-scoped keys are addressed as `sectionId:name`.
|
|
140
167
|
|
|
168
|
+
## Editor support
|
|
169
|
+
|
|
170
|
+
A VS Code extension in [`editors/vscode`](editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding — so a `.wd` file reads as Markdown-plus-directives, never as broken Markdown. Build it with `cd editors/vscode && npx @vscode/vsce package`, or install the published extension from the Marketplace (search "Darkmown").
|
|
171
|
+
|
|
141
172
|
## Spec status
|
|
142
173
|
|
|
143
174
|
See [docs/spec-alignment.md](docs/spec-alignment.md) for the deep alignment audit against the original vision.
|
package/docs/spec-alignment.md
CHANGED
|
@@ -20,7 +20,7 @@ This audit compares the current Darkmown implementation with the original MARROW
|
|
|
20
20
|
- **`:fetch name from "url"`:** declarative data loading into reactive state, with `name_error` for failures. Shelf `.json` publishes to `/__wd/data/` so it works on static hosts.
|
|
21
21
|
- **`:form` two ways:** `:form into name` captures submits into client state with zero backend; `:form action="/url"` emits a plain native form and ships no JS — the progressive-enhancement story from the original doc.
|
|
22
22
|
- **`:state x = v persist`:** localStorage-backed state. This is the honest client-side slice of the original `:cart` primitive; server sync remains future work.
|
|
23
|
-
- **`:if item.path` inside reactive loops:** per-row conditional branches, filled at compile time and re-filled by the keyed patcher.
|
|
23
|
+
- **`:if item.path` inside reactive loops:** per-row conditional branches, filled at compile time and re-filled by the keyed patcher. Conditionals **nest** — an inner `:if` is resolved after the outer branch, both at build time (balanced-region pre-render) and at runtime (recursive fill).
|
|
24
24
|
- **`window.wd` escape hatch:** `get`/`set`/`state`/`render` exposed to colocated `.js` — the adapted form of the original `<script scope>` "infinite extension" idea, aligned with Darkmown's colocation model instead of inline scripts.
|
|
25
25
|
|
|
26
26
|
## Deliberate adaptations
|
|
@@ -46,10 +46,19 @@ 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.
|
|
52
|
-
-
|
|
61
|
+
- `@loop` over fetch sub-paths (looping a path *inside* fetched state rather than top-level state).
|
|
53
62
|
- `@starting-style` emission and browser fallbacks for older engines.
|
|
54
63
|
- Editor tooling, richer diagnostics, and an actual npm publish (needs a license decision).
|
|
55
64
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvndev/darkmown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
"scripts": {
|
|
14
14
|
"build": "node src/cli.js build",
|
|
15
15
|
"dev": "node src/cli.js dev",
|
|
16
|
-
"test": "node --test"
|
|
16
|
+
"test": "node --test tests/*.test.js"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
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/_/nav.skin
CHANGED
package/site/_/nav.wd
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
[
|
|
2
|
+
{ "id": "loops", "title": "Nested conditionals in loops", "shipped": true, "blocked": false },
|
|
3
|
+
{ "id": "editor", "title": "VS Code syntax highlighting", "shipped": true, "blocked": false },
|
|
4
|
+
{ "id": "filter", "title": "State-driven list filtering", "shipped": false, "blocked": false },
|
|
5
|
+
{ "id": "api", "title": "First-party site/api/ runtime", "shipped": false, "blocked": true },
|
|
6
|
+
{ "id": "swap", "title": "HTML-fragment swap responses", "shipped": false, "blocked": false }
|
|
7
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
tokens
|
|
2
|
+
ink #171717
|
|
3
|
+
paper #fbfaf7
|
|
4
|
+
panel #ffffff
|
|
5
|
+
line #e4e1d8
|
|
6
|
+
accent #16645a
|
|
7
|
+
warm #a83f2a
|
|
8
|
+
radius 10px
|
|
9
|
+
|
|
10
|
+
main
|
|
11
|
+
max-width 760px
|
|
12
|
+
margin 0 auto
|
|
13
|
+
padding 2.5rem 1.5rem 4rem
|
|
14
|
+
font ui-sans-serif, system-ui, sans-serif
|
|
15
|
+
color $ink
|
|
16
|
+
|
|
17
|
+
h1
|
|
18
|
+
font-size 2.1rem
|
|
19
|
+
letter-spacing -0.02em
|
|
20
|
+
|
|
21
|
+
.boundary
|
|
22
|
+
margin 1.5rem 0
|
|
23
|
+
padding 1rem 1.2rem
|
|
24
|
+
border-left 3px solid $warm
|
|
25
|
+
background #fcf4ef
|
|
26
|
+
border-radius $radius
|
|
27
|
+
font-size .95rem
|
|
28
|
+
|
|
29
|
+
.roadmap-row
|
|
30
|
+
display flex
|
|
31
|
+
align-items center
|
|
32
|
+
justify-content space-between
|
|
33
|
+
gap 1rem
|
|
34
|
+
padding .75rem 1rem
|
|
35
|
+
margin .5rem 0
|
|
36
|
+
background $panel
|
|
37
|
+
border 1px solid $line
|
|
38
|
+
border-radius $radius
|
|
39
|
+
|
|
40
|
+
.badge
|
|
41
|
+
font-size .8rem
|
|
42
|
+
font-weight 600
|
|
43
|
+
padding .2rem .6rem
|
|
44
|
+
border-radius 999px
|
|
45
|
+
white-space nowrap
|
|
46
|
+
|
|
47
|
+
.badge.shipped
|
|
48
|
+
background #e3f3ec
|
|
49
|
+
color $accent
|
|
50
|
+
|
|
51
|
+
.badge.wip
|
|
52
|
+
background #fef0e6
|
|
53
|
+
color $warm
|
|
54
|
+
|
|
55
|
+
.badge.blocked
|
|
56
|
+
background #f3e3e3
|
|
57
|
+
color #9a2a2a
|
|
58
|
+
|
|
59
|
+
button
|
|
60
|
+
background $accent
|
|
61
|
+
color white
|
|
62
|
+
border 0
|
|
63
|
+
padding .55rem .9rem
|
|
64
|
+
margin .25rem .4rem .25rem 0
|
|
65
|
+
border-radius 8px
|
|
66
|
+
font-size .95rem
|
|
67
|
+
cursor pointer
|
|
68
|
+
|
|
69
|
+
input
|
|
70
|
+
font-size 1rem
|
|
71
|
+
padding .6rem .7rem
|
|
72
|
+
border 1px solid $line
|
|
73
|
+
border-radius 8px
|
|
74
|
+
width 100%
|
|
75
|
+
max-width 22rem
|
|
76
|
+
margin .3rem 0
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: A live app in one Markdown file
|
|
3
|
+
description: One .wd file — static prose, fetched data, looped results with nested conditionals, a persistent counter, and a form that round-trips to a real server. No JavaScript written, no components, no build config.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
@include /nav.wd
|
|
7
|
+
|
|
8
|
+
<main>
|
|
9
|
+
|
|
10
|
+
# A live app, in one Markdown file
|
|
11
|
+
|
|
12
|
+
Everything on this page — the prose, the fetched list, the badges, the form, the counter — is a single `.wd` file. No components. No JSX. No build config. Watch where JavaScript actually enters.
|
|
13
|
+
|
|
14
|
+
## Static until proven reactive
|
|
15
|
+
|
|
16
|
+
This whole section is plain Markdown. The list below is unrolled **at build time** from a JSON file — it renders into HTML with no runtime at all:
|
|
17
|
+
|
|
18
|
+
@loop /features.json into feature
|
|
19
|
+
- **{ feature.name }** — { feature.detail }
|
|
20
|
+
@endloop
|
|
21
|
+
|
|
22
|
+
<aside class="boundary">If you deleted everything below this line, this page would ship <strong>zero</strong> bytes of JavaScript. Everything above is static HTML. Below is where the one ~2 KB runtime loads — and only because the document asks for it.</aside>
|
|
23
|
+
|
|
24
|
+
## Fetched data, looped, with nested conditions
|
|
25
|
+
|
|
26
|
+
`:fetch` pulls JSON over the network into reactive state — from a static file here, or any API. Until it lands, the page shows a fallback. Each row then decides its own badge from **nested** conditionals, in plain Markdown:
|
|
27
|
+
|
|
28
|
+
:fetch roadmap from "/__wd/data/roadmap.json"
|
|
29
|
+
|
|
30
|
+
:if roadmap
|
|
31
|
+
@loop roadmap into item
|
|
32
|
+
::: card .roadmap-row
|
|
33
|
+
**{ item.title }**
|
|
34
|
+
:if item.blocked
|
|
35
|
+
<span class="badge blocked">blocked</span>
|
|
36
|
+
:else
|
|
37
|
+
:if item.shipped
|
|
38
|
+
<span class="badge shipped">shipped ✓</span>
|
|
39
|
+
:else
|
|
40
|
+
<span class="badge wip">in progress</span>
|
|
41
|
+
:endif
|
|
42
|
+
:endif
|
|
43
|
+
:::
|
|
44
|
+
@endloop
|
|
45
|
+
:else
|
|
46
|
+
Loading the roadmap…
|
|
47
|
+
:endif
|
|
48
|
+
|
|
49
|
+
No ternaries, no `.map()`, no framework — the outer `:if` checks `blocked`, the inner one checks `shipped`, per row, and both stay reactive.
|
|
50
|
+
|
|
51
|
+
## A counter that survives reload
|
|
52
|
+
|
|
53
|
+
`:state … persist` keeps this in `localStorage`, and `:computed` derives from it. Star a few, reload the page, and it holds:
|
|
54
|
+
|
|
55
|
+
::: section #watch
|
|
56
|
+
:state stars = 0 persist
|
|
57
|
+
:computed milestone = stars >= 3
|
|
58
|
+
|
|
59
|
+
You are watching { stars } item(s).
|
|
60
|
+
|
|
61
|
+
:button "★ Watch one" -> stars++
|
|
62
|
+
:button "Reset" -> stars = 0
|
|
63
|
+
|
|
64
|
+
:if milestone
|
|
65
|
+
Nice — you are tracking the whole near-term roadmap.
|
|
66
|
+
:endif
|
|
67
|
+
:::
|
|
68
|
+
|
|
69
|
+
## A form that talks to a real server
|
|
70
|
+
|
|
71
|
+
This form posts to a live serverless function and shows the reply. With JavaScript it is a `fetch`; without it, the exact same markup is a native POST — Darkmown adapts to your backend instead of owning one:
|
|
72
|
+
|
|
73
|
+
:form action="/__wd/echo" into reply
|
|
74
|
+
:input note placeholder="Tell us what you'd build with this"
|
|
75
|
+
:submit "Send to the server"
|
|
76
|
+
:endform
|
|
77
|
+
|
|
78
|
+
:if reply
|
|
79
|
+
The server got it and answered at **{ reply.at }**.
|
|
80
|
+
:else
|
|
81
|
+
Your reply will appear here.
|
|
82
|
+
:endif
|
|
83
|
+
|
|
84
|
+
:if reply_error
|
|
85
|
+
Something went wrong: { reply_error }
|
|
86
|
+
:endif
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
That was one file. Static prose, build-time loops, fetched data, nested conditionals, persistent state, computed values, and a server round-trip — and the only JavaScript on the page is the shared runtime Darkmown loads for you. [Read the docs](/docs/) or [deploy your own](/#deploy).
|
|
91
|
+
|
|
92
|
+
</main>
|