@zvndev/darkmown 0.6.0 → 0.8.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 +13 -1
- package/CHANGELOG.md +14 -0
- package/README.md +44 -0
- package/docs/okf-integration.md +120 -0
- package/docs/spec-alignment.md +5 -0
- package/package.json +1 -1
- package/site/pages/docs/index.wd +29 -0
- package/site/pages/reactive.skin +30 -0
- package/site/pages/reactive.wd +16 -1
- package/src/compiler.js +72 -6
- package/src/runtime.js +23 -0
package/AGENTS.md
CHANGED
|
@@ -27,6 +27,8 @@ Commands: `darkmown dev`, `darkmown build`, `darkmown serve`.
|
|
|
27
27
|
|
|
28
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
29
|
|
|
30
|
+
The page's frontmatter is in scope as `meta`: `{ meta.title }` prints a field, `{ meta.tags }` joins an array with `, `, and `@loop meta.tags into tag` iterates a frontmatter array (build-time, stays static). Frontmatter arrays are inline only — `tags: [a, b, "x, y"]`; a value without a leading `[` is a plain string.
|
|
31
|
+
|
|
30
32
|
## Directives — the complete set (nothing else exists)
|
|
31
33
|
|
|
32
34
|
### State and buttons
|
|
@@ -41,7 +43,17 @@ Count: { count }
|
|
|
41
43
|
:button "Add item" -> cart += {"id": 1, "name": "Sticker"}
|
|
42
44
|
:button "Reset" -> count = 0
|
|
43
45
|
```
|
|
44
|
-
Button actions are a **fixed whitelist**: `x++`, `x--`, `x += <number>`, `list += <json>`, `x = <json>`. Target must be declared `:state`.
|
|
46
|
+
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="…">`.
|
|
47
|
+
|
|
48
|
+
**Per-row actions (inside a reactive `@loop … into item`):** two actions reference the current row instead of a JSON literal —
|
|
49
|
+
```wd
|
|
50
|
+
@loop products into product
|
|
51
|
+
:button "Add to cart" -> cart += product ← append a COPY of this row to another :state list
|
|
52
|
+
:::
|
|
53
|
+
@loop cart into line
|
|
54
|
+
:button "Remove" -> cart remove line ← drop this row from the list being looped
|
|
55
|
+
```
|
|
56
|
+
`cart += product` is the only way to carry a loop item into another list (literals still can't reference rows). `<list> remove <item>`: `<list>` must be the loop's own `:state` source and `<item>` the loop variable (both compile-checked). This is the canonical add-to-cart / remove-line / delete-todo pattern — no `.js` needed. Removal targets the exact row, so it is correct even under a `where` filter.
|
|
45
57
|
|
|
46
58
|
### Conditionals
|
|
47
59
|
```wd
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
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.8.0 — 2026-06-14
|
|
6
|
+
|
|
7
|
+
- **Array frontmatter + readable `meta`.** Frontmatter now parses inline flow arrays — `tags: [sales, revenue, "q1, q2"]` becomes a real array (previously the literal string `"[sales, revenue]"`), with quoted items keeping their internal commas and bracket-free values staying plain scalars. Block sequences (`- item` lines) remain out of scope to keep the parser single-pass.
|
|
8
|
+
- A page's frontmatter is now in scope in its body as `meta`: `{ meta.title }` prints a field, `{ meta.tags }` renders an array joined with `, `, and `@loop meta.tags into tag` iterates a frontmatter array at build time (stays `runtime: false`, zero-JS).
|
|
9
|
+
- A correctness fix in its own right, and the groundwork for reading/emitting bundles in Google Cloud's Open Knowledge Format (markdown + `type`-bearing frontmatter + cross-links). OKF support, if pursued, will be a thin additive export — deliberately not coupled to its v0.1 Draft.
|
|
10
|
+
|
|
11
|
+
## 0.7.0 — 2026-06-13
|
|
12
|
+
|
|
13
|
+
- **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:
|
|
14
|
+
- `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.
|
|
15
|
+
- `<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.
|
|
16
|
+
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.
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
5
19
|
## 0.6.0 — 2026-06-13
|
|
6
20
|
|
|
7
21
|
- **State-driven list filtering — `@loop … where <predicate>`.** Filter any loop with conditions that join via `and` / `or`. Operators: `==` `!=` `<` `<=` `>` `>=` and `contains` (case-insensitive substring). Operands are item paths, declared `:state`, numbers, or `"strings"` — a compile-time whitelist (no expressions, no `eval`; `constructor`/`prototype`/`__proto__` rejected in compiler and runtime). **The predicate decides reactivity:** an item-only predicate filters at build time and the page stays zero-JS; a predicate that reads `:state` compiles to a reactive filtered loop driven by the keyed reconciler (rows are baked in for static JSON sources). This replaces the fragile DOM-toggle escape hatch — the #1 gap surfaced by the agent-eval benchmark.
|
package/README.md
CHANGED
|
@@ -54,8 +54,24 @@ One syntax everywhere: `{ name }` or `{ name.path }`.
|
|
|
54
54
|
|
|
55
55
|
- In-scope static values (include arguments, loop values) resolve at build time.
|
|
56
56
|
- Declared `:state` becomes a live binding.
|
|
57
|
+
- The page's own frontmatter is in scope as `meta` — `{ meta.title }` prints a field.
|
|
57
58
|
- Anything else stays literal text — braces in prose never break a page or pull in the runtime.
|
|
58
59
|
|
|
60
|
+
## Frontmatter
|
|
61
|
+
|
|
62
|
+
YAML-style `key: value` frontmatter between `---` fences. Values are strings, plus inline arrays:
|
|
63
|
+
|
|
64
|
+
```wd
|
|
65
|
+
---
|
|
66
|
+
title: Customers
|
|
67
|
+
tags: [sales, revenue, "q1, q2"]
|
|
68
|
+
---
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- `{ meta.title }` prints a scalar; `{ meta.tags }` prints an array joined with `, `.
|
|
72
|
+
- `@loop meta.tags into tag` iterates an array field at build time (stays static, zero-JS).
|
|
73
|
+
- Arrays are inline flow only (`[a, b]`); quoted items keep internal commas (`"q1, q2"`). A value without a leading `[` stays a plain string.
|
|
74
|
+
|
|
59
75
|
## Loops
|
|
60
76
|
|
|
61
77
|
`@loop <things> into <thing>` is the only loop. The source decides the behavior:
|
|
@@ -101,6 +117,34 @@ Operators: `==` `!=` `<` `<=` `>` `>=`, plus `contains` for case-insensitive sub
|
|
|
101
117
|
|
|
102
118
|
`: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
119
|
|
|
120
|
+
### Editable lists — per-row actions
|
|
121
|
+
|
|
122
|
+
A `:button` inside a reactive `@loop` can act on its own row. Two row actions exist:
|
|
123
|
+
|
|
124
|
+
```wd
|
|
125
|
+
:state products = [{"id": 1, "name": "Aurora", "price": 49}]
|
|
126
|
+
:state cart = []
|
|
127
|
+
|
|
128
|
+
@loop products into product
|
|
129
|
+
::: card
|
|
130
|
+
**{ product.name }** — ${ product.price }
|
|
131
|
+
:button "Add to cart" -> cart += product <- carry this row into another list
|
|
132
|
+
:::
|
|
133
|
+
@endloop
|
|
134
|
+
|
|
135
|
+
@loop cart into line
|
|
136
|
+
::: card
|
|
137
|
+
{ line.name }
|
|
138
|
+
:button "Remove" -> cart remove line <- drop this row from the looped list
|
|
139
|
+
:::
|
|
140
|
+
@endloop
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- `cart += <item>` appends a **copy** of the current row to another `:state` list, so adding the same product twice gives two independent lines.
|
|
144
|
+
- `<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`.
|
|
145
|
+
|
|
146
|
+
That is a full add-to-cart / remove-line flow — and a to-do list with delete — in plain Markdown, no JavaScript.
|
|
147
|
+
|
|
104
148
|
## Sections
|
|
105
149
|
|
|
106
150
|
```wd
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# OKF integration — plan & options
|
|
2
|
+
|
|
3
|
+
Status: **parked / decision pending** (2026-06-14). Research-verified; revisit when
|
|
4
|
+
we decide how far to take it. v0.8.0 (array frontmatter + readable `meta`) already
|
|
5
|
+
shipped the enabling groundwork.
|
|
6
|
+
|
|
7
|
+
## What OKF is (verified)
|
|
8
|
+
|
|
9
|
+
**Open Knowledge Format (OKF)** is real. Published **2026-06-12** by the Google Cloud
|
|
10
|
+
Data Analytics team (Sam McVeety, Amir Hormati).
|
|
11
|
+
|
|
12
|
+
- Announcement: <https://cloud.google.com/blog/products/data-analytics/how-the-open-knowledge-format-can-improve-data-sharing/>
|
|
13
|
+
- Spec: <https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md>
|
|
14
|
+
- Repo (Apache-2.0, `okf/` subdir): <https://github.com/GoogleCloudPlatform/knowledge-catalog>
|
|
15
|
+
- **Not** the Open Knowledge Foundation (unrelated nonprofit — do not conflate).
|
|
16
|
+
|
|
17
|
+
It is a **file format**, not a service: a directory of markdown "concept" files for
|
|
18
|
+
feeding organizational knowledge to AI agents. Each concept file has YAML frontmatter
|
|
19
|
+
where **`type` is the only required field**; `title`, `description`, `resource` (a URI
|
|
20
|
+
for the underlying asset), `tags` (a YAML list), and `timestamp` (ISO 8601) are
|
|
21
|
+
optional. Producers may add arbitrary keys; consumers must preserve unknown keys.
|
|
22
|
+
|
|
23
|
+
- **Cross-links** are ordinary markdown links between concept files (bundle-relative
|
|
24
|
+
absolute like `/tables/customers.md` recommended). They are treated as **directed,
|
|
25
|
+
untyped graph edges** — the relationship's meaning lives in surrounding prose.
|
|
26
|
+
- **Reserved files:** `index.md` (a directory listing for *progressive disclosure* — an
|
|
27
|
+
agent navigates one level at a time) and `log.md` (chronological history). The root
|
|
28
|
+
`index.md` frontmatter may declare `okf_version: "0.1"`.
|
|
29
|
+
- **Conformance is permissive:** consumers MUST tolerate broken links, unknown `type`
|
|
30
|
+
values, unknown keys, and missing optional fields (so agent-generated, partial bundles
|
|
31
|
+
still work).
|
|
32
|
+
- **Reference tooling (both labeled proof-of-concept):** a BigQuery *enrichment agent*
|
|
33
|
+
that drafts OKF docs per table, and a *visualizer* — a `visualize` command that emits a
|
|
34
|
+
single self-contained interactive HTML file (Cytoscape.js force-graph + marked.js body,
|
|
35
|
+
no backend).
|
|
36
|
+
- **Sample bundles:** GA4 e-commerce, Stack Overflow, Bitcoin. ("BigQuery Table /
|
|
37
|
+
Dataset / Metric" are concept *types inside* bundles, not separate samples.)
|
|
38
|
+
|
|
39
|
+
## Why it's interesting for Darkmown
|
|
40
|
+
|
|
41
|
+
OKF's entire surface — YAML frontmatter + a folder tree + relative markdown links →
|
|
42
|
+
static HTML — **is what Darkmown already does**. A `.md` tree with a `type:` field and
|
|
43
|
+
cross-links is *nearly an OKF bundle by accident*. Two directions:
|
|
44
|
+
|
|
45
|
+
1. **Darkmown as an OKF renderer.** Darkmown renders markdown bundles to static, zero-JS
|
|
46
|
+
HTML — directly competitive with OKF's reference visualizer, which is OKF's weakest
|
|
47
|
+
surface (a single JS-heavy HTML file).
|
|
48
|
+
2. **Every Darkmown site emits an OKF bundle.** Any docs/content site also becomes
|
|
49
|
+
machine-readable agent context. No other markdown framework offers this.
|
|
50
|
+
|
|
51
|
+
## Why to be cautious
|
|
52
|
+
|
|
53
|
+
- **v0.1 "Draft," explicitly evolving.** Google's own blog: "a starting point, not a
|
|
54
|
+
finished standard." Field names and required fields may change (minor versions promise
|
|
55
|
+
backward-compatible additions only; breaking changes need a major bump).
|
|
56
|
+
- **Thin traction.** ~1.2k GitHub stars but ~8 issues, ~13 PRs, ~27 commits at launch.
|
|
57
|
+
**No third-party adopters** — only Google's own Cloud Knowledge Catalog consumes it.
|
|
58
|
+
- **Governance unclear.** Lives under Google's repo with no foundation/working group.
|
|
59
|
+
- **Thin convention.** Net-new over "a markdown vault + Obsidian/Dataview" is small: one
|
|
60
|
+
required field, two reserved filenames, link-as-edge semantics. Easy for others to
|
|
61
|
+
ignore or fork.
|
|
62
|
+
|
|
63
|
+
## Decision (Kirby, 2026-06-14)
|
|
64
|
+
|
|
65
|
+
**Support OKF naturally; do not tie ourselves to it.** Treat it as a cheap, additive
|
|
66
|
+
interop convention, not a strategic bet or a core-model change.
|
|
67
|
+
|
|
68
|
+
## Options (revisit later)
|
|
69
|
+
|
|
70
|
+
### Option A — Do nothing more (status quo)
|
|
71
|
+
v0.8.0 array frontmatter already makes Darkmown frontmatter able to carry `tags` lists
|
|
72
|
+
and a `type` field. A Darkmown content tree with `type:` in frontmatter is already
|
|
73
|
+
~conformant. Cost: zero. Keep tracking adoption.
|
|
74
|
+
|
|
75
|
+
### Option B — Thin OKF emit (recommended first step if we act) — increment #3
|
|
76
|
+
A flag-gated export from a built site: `okf: true` in config or `--emit-okf`.
|
|
77
|
+
- During `buildSite` (`src/builder.js`), alongside `routes.json`, write `dist/okf/`.
|
|
78
|
+
- For each page, emit an OKF doc: frontmatter (`type` defaulting to `page`, `title` from
|
|
79
|
+
meta, `tags` from meta, `timestamp` from file mtime/git) + the page's markdown body.
|
|
80
|
+
- Derive edges from internal links + `@include` edges (`resolveInclude`/`compileFile`
|
|
81
|
+
already know these). Emit per-directory `index.md` for progressive disclosure.
|
|
82
|
+
- **Map at emit time** — do not restructure Darkmown's internal model around OKF.
|
|
83
|
+
- Effort: low; it hooks the existing route loop. Largely free.
|
|
84
|
+
|
|
85
|
+
### Option C — OKF render mode ("best OKF renderer") — increment #2
|
|
86
|
+
`darkmown build --okf ./bundle [--out dist-okf]`: walk an arbitrary bundle root, render
|
|
87
|
+
each doc (type-aware templates, generic fallback), rewrite `.md` cross-links to routes,
|
|
88
|
+
emit a static index/graph view. Reuse `parseFrontmatter`, markdown-it, `compileFile` —
|
|
89
|
+
do not fork the pipeline. Must stay `runtime: false`.
|
|
90
|
+
- **Gate this on OKF showing real external adoption.** Today you'd build a renderer for a
|
|
91
|
+
format only Google uses. Higher effort, higher coupling risk.
|
|
92
|
+
|
|
93
|
+
### Sequencing if we proceed
|
|
94
|
+
1. (done) Array frontmatter + readable `meta` — v0.8.0.
|
|
95
|
+
2. Option B (emit) — additive, low-regret.
|
|
96
|
+
3. Option C (render) — only if the format gets stickier.
|
|
97
|
+
|
|
98
|
+
## Hard "avoid" list
|
|
99
|
+
|
|
100
|
+
- Don't hardcode Google/BigQuery `type` vocabularies (`BigQuery Table`, etc.) — `type` is
|
|
101
|
+
uncentralized and producer-defined.
|
|
102
|
+
- Don't bet on v0.1 field names / required-field stability — map our frontmatter → OKF at
|
|
103
|
+
emit time.
|
|
104
|
+
- Don't depend on OKF's reference enrichment agent or visualizer — both are proofs of
|
|
105
|
+
concept. If we want a graph view, build our own; the format is trivial.
|
|
106
|
+
- Don't market "OKF-native" as a moat — it's a thin convention with thin adoption. A
|
|
107
|
+
cheap interoperability checkbox, not a strategic bet.
|
|
108
|
+
|
|
109
|
+
## Invariants to respect (if/when we build)
|
|
110
|
+
|
|
111
|
+
- `.md` never gains directive behavior; OKF docs are content, rendered as content.
|
|
112
|
+
- No eval of bundle content; the `constructor`/`prototype`/`__proto__` rejection in
|
|
113
|
+
`getPath` still applies.
|
|
114
|
+
- OKF render/emit are pure content transforms — `runtime: false` always.
|
|
115
|
+
- Compile/render errors include the offending file path and a corrective suggestion.
|
|
116
|
+
|
|
117
|
+
## Source
|
|
118
|
+
|
|
119
|
+
Full research briefing (cited): the research-investigator memory
|
|
120
|
+
`reference_okf_open_knowledge_format.md`, and `docs/spec-alignment.md` Stage 7.
|
package/docs/spec-alignment.md
CHANGED
|
@@ -51,10 +51,15 @@ This audit compares the current Darkmown implementation with the original MARROW
|
|
|
51
51
|
- **Nested `:if` over loop items (v0.5.0):** conditionals nest inside reactive `@loop`, per row. Both fill paths fixed — build-time balanced `matchElement` recursion and runtime `fillItem` recursion.
|
|
52
52
|
- **State-driven list filtering — `@loop … where <predicate>`:** the #1 need surfaced by the agent-eval benchmark. Conditions (`field <op> value`, plus `contains`) join with `and` / `or` over a compile-time-validated whitelist — item paths, declared `:state`, numbers, strings; `constructor`/`prototype`/`__proto__` rejected, no `eval`. The source decides reactivity, matching the loop itself: an item-only predicate filters at build time and stays zero-JS; a predicate that reads `:state` compiles to a reactive filtered loop (rows baked in for static sources). This replaces the fragile DOM-toggle escape hatch with the keyed reconciler.
|
|
53
53
|
- **`:bind <state>`:** two-way `<input>` bound to a `:state` value — the input primitive that powers live search. Accepts `type=`, `placeholder=`, `autocomplete=`, `required`, `autofocus`.
|
|
54
|
+
- **Per-row actions in reactive loops:** a `:button` inside `@loop … into item` can act on its own row — `cart += item` appends a copy of the row to another `:state` list (the only way to carry a loop item into another list), and `list remove item` removes the row from the looped `:state` source (both names compile-checked against the enclosing loop). The runtime resolves the clicked row by stamping each reconciled node with its item, so removal is exact even under a `where` filter. This closes the last "store demo" gap noted in the design memory (per-row `:button` couldn't carry row data); `:if` string-equality remains the only listed gap, now partly covered by `@loop … where`.
|
|
54
55
|
- **`.skin` robustness:** `@media`/`@supports` at-rules wrap nested rules; the `font` shorthand passes through (size-led or containing `/`) while `font <stack>` still aliases to `font-family`; `/* … */` block comments and decorative divider lines are skipped.
|
|
55
56
|
- **Editor tooling:** VS Code extension (`editors/vscode`) ships `.wd` + `.skin` TextMate grammars, snippets, and folding, grammar-tested in CI.
|
|
56
57
|
- **`AGENTS.md`:** a model-facing build guide (shipped in the npm files array), tuned over a 5-round agent-eval benchmark that lifted build quality from 2.78→4.51/5.
|
|
57
58
|
|
|
59
|
+
## Stage 7 additions (2026-06-14)
|
|
60
|
+
|
|
61
|
+
- **Array frontmatter + readable `meta`:** `parseFrontmatter` now parses inline flow arrays (`tags: [a, b, "x, y"]` → real array; quoted items keep internal commas; a value without a leading `[` stays a scalar — block sequences are intentionally out of scope to keep the parser single-pass). A page's frontmatter is exposed to its body under `meta`, so `{ meta.title }` prints a field, `{ meta.tags }` renders an array joined with `, `, and `@loop meta.tags into tag` iterates a frontmatter array at build time (stays `runtime: false`). Genuine correctness fix on its own, and the groundwork for reading/emitting OKF-style bundles (Google Cloud's Open Knowledge Format — markdown + `type`-bearing frontmatter + cross-links; tracked but deliberately not coupled to its v0.1 Draft).
|
|
62
|
+
|
|
58
63
|
## Still missing from the full vision
|
|
59
64
|
|
|
60
65
|
- 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
package/site/pages/docs/index.wd
CHANGED
|
@@ -88,14 +88,43 @@ The predicate decides reactivity exactly like the source does: read only the row
|
|
|
88
88
|
|
|
89
89
|
`:bind <state>` renders an `<input>` wired two-way to a `:state` value — type and the list filters as you go.
|
|
90
90
|
|
|
91
|
+
### Editable lists with per-row actions
|
|
92
|
+
|
|
93
|
+
A `:button` inside a reactive loop can act on its own row — the basis for carts and to-do lists:
|
|
94
|
+
|
|
95
|
+
```wd
|
|
96
|
+
@loop products into product
|
|
97
|
+
:button "Add to cart" -> cart += product
|
|
98
|
+
:::
|
|
99
|
+
@loop cart into line
|
|
100
|
+
:button "Remove" -> cart remove line
|
|
101
|
+
:::
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`cart += product` appends a copy of the current row to another `:state` list; `cart remove line` removes the current row from the list being looped. Both are checked against the enclosing loop at compile time — no JavaScript.
|
|
105
|
+
|
|
91
106
|
## Interpolation
|
|
92
107
|
|
|
93
108
|
One syntax everywhere: `{ name }` or `{ name.path }`.
|
|
94
109
|
|
|
95
110
|
- If the name is a static value in scope (include argument, loop value), it is resolved at build time.
|
|
96
111
|
- If the name is declared `:state`, it becomes a live binding.
|
|
112
|
+
- The page's frontmatter is in scope as `meta` — `{ meta.title }` prints a field.
|
|
97
113
|
- Otherwise the text is left exactly as written — braces in prose never break a page or pull in the runtime.
|
|
98
114
|
|
|
115
|
+
## Frontmatter
|
|
116
|
+
|
|
117
|
+
Frontmatter sits between `---` fences. Values are strings, plus inline arrays:
|
|
118
|
+
|
|
119
|
+
```wd
|
|
120
|
+
---
|
|
121
|
+
title: Customers
|
|
122
|
+
tags: [sales, revenue, "q1, q2"]
|
|
123
|
+
---
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`{ meta.title }` prints a scalar and `{ meta.tags }` joins an array with `, `. You can loop a frontmatter array at build time — `@loop meta.tags into tag` — and the page stays static. Arrays are inline flow only (`[a, b]`); quoted items keep their commas.
|
|
127
|
+
|
|
99
128
|
## Sections
|
|
100
129
|
|
|
101
130
|
`::: section #id .class` opens a container and `:::` closes it. Sections scope state: a `:state` declared inside a section belongs to that section, so two sections can both declare `count` without colliding. Bindings and buttons resolve to the nearest scope.
|
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
|
@@ -69,16 +69,31 @@ Each row picks one of three states from two nested conditionals, all in plain Ma
|
|
|
69
69
|
`@loop … where` filters a list, and because the predicate reads a `:state` value, the loop re-filters live as you type. `:bind` is the input bound two-way to that state. Type below and watch the list narrow — no event handlers, no JavaScript:
|
|
70
70
|
|
|
71
71
|
:state products = [{"id": 1, "name": "Aurora Lamp", "price": 49}, {"id": 2, "name": "Briza Fan", "price": 39}, {"id": 3, "name": "Cove Speaker", "price": 89}, {"id": 4, "name": "Dune Mug", "price": 12}, {"id": 5, "name": "Ember Kettle", "price": 64}]
|
|
72
|
+
:state cart = []
|
|
72
73
|
:state q = ""
|
|
73
74
|
|
|
74
75
|
:bind q placeholder="Search products…"
|
|
75
76
|
|
|
76
77
|
@loop products into p where p.name contains q
|
|
77
|
-
|
|
78
|
+
::: card .product
|
|
79
|
+
**{ p.name }** — ${ p.price }
|
|
80
|
+
:button "Add to cart" -> cart += p
|
|
81
|
+
:::
|
|
78
82
|
@endloop
|
|
79
83
|
|
|
80
84
|
The predicate whitelist is compile-time validated — item fields, declared state, numbers, and strings only. An item-only predicate (say `where p.price < 50`) would instead filter at build time and ship zero JavaScript.
|
|
81
85
|
|
|
86
|
+
### …and an editable cart
|
|
87
|
+
|
|
88
|
+
A `:button` inside a loop can act on its own row. **Add to cart** carries the row into another list; **Remove** drops a line — a whole add/remove flow with no JavaScript:
|
|
89
|
+
|
|
90
|
+
@loop cart into line
|
|
91
|
+
::: card .line
|
|
92
|
+
{ line.name } — ${ line.price }
|
|
93
|
+
:button "Remove" -> cart remove line
|
|
94
|
+
:::
|
|
95
|
+
@endloop
|
|
96
|
+
|
|
82
97
|
## Section-scoped state
|
|
83
98
|
|
|
84
99
|
Two sections can each own a state value named `tally` without colliding. Buttons resolve to the nearest scope:
|
package/src/compiler.js
CHANGED
|
@@ -82,7 +82,9 @@ function compileFile(file, context, stack, scope, comp, sections, loopItem) {
|
|
|
82
82
|
return { meta, html: md.render(body, {}) };
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
// Expose this file's frontmatter to the body under `meta` so `{ meta.title }`,
|
|
86
|
+
// `{ meta.tags }`, and `@loop meta.tags into tag` resolve as static values.
|
|
87
|
+
const ctx = { file, context, stack: [...stack, real], scope: createScope(scope, { meta }), comp, sections, loopItem };
|
|
86
88
|
return { meta, html: compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx) };
|
|
87
89
|
}
|
|
88
90
|
|
|
@@ -95,11 +97,49 @@ export function parseFrontmatter(raw) {
|
|
|
95
97
|
const meta = {};
|
|
96
98
|
for (const line of front.split("\n")) {
|
|
97
99
|
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
98
|
-
if (match) meta[match[1]] =
|
|
100
|
+
if (match) meta[match[1]] = parseFrontmatterValue(match[2]);
|
|
99
101
|
}
|
|
100
102
|
return { meta, body };
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
// Frontmatter values are scalars, except an inline flow array `[a, b, c]`.
|
|
106
|
+
// Block sequences (`- item` on following lines) are intentionally out of scope —
|
|
107
|
+
// the parser stays single-pass and line-based.
|
|
108
|
+
function parseFrontmatterValue(raw) {
|
|
109
|
+
const value = raw.trim();
|
|
110
|
+
if (value.startsWith("[") && value.endsWith("]")) return parseInlineArray(value);
|
|
111
|
+
return stripQuotes(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseInlineArray(raw) {
|
|
115
|
+
const inner = raw.slice(1, -1);
|
|
116
|
+
if (inner.trim() === "") return [];
|
|
117
|
+
const items = [];
|
|
118
|
+
let buf = "";
|
|
119
|
+
let quote = null;
|
|
120
|
+
let quoted = false;
|
|
121
|
+
const push = () => {
|
|
122
|
+
items.push(quoted ? buf : buf.trim());
|
|
123
|
+
buf = "";
|
|
124
|
+
quoted = false;
|
|
125
|
+
};
|
|
126
|
+
for (const char of inner) {
|
|
127
|
+
if (quote) {
|
|
128
|
+
if (char === quote) quote = null;
|
|
129
|
+
else buf += char;
|
|
130
|
+
} else if ((char === '"' || char === "'") && buf.trim() === "") {
|
|
131
|
+
quote = char;
|
|
132
|
+
quoted = true;
|
|
133
|
+
} else if (char === ",") {
|
|
134
|
+
push();
|
|
135
|
+
} else {
|
|
136
|
+
buf += char;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
push();
|
|
140
|
+
return items;
|
|
141
|
+
}
|
|
142
|
+
|
|
103
143
|
// ---------------------------------------------------------------------------
|
|
104
144
|
// Block parser: directives + prose segments
|
|
105
145
|
// ---------------------------------------------------------------------------
|
|
@@ -705,7 +745,7 @@ function staticUnroll(rows, itemName, bodyLines, ctx) {
|
|
|
705
745
|
|
|
706
746
|
function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
707
747
|
ctx.comp.assets.runtime = true;
|
|
708
|
-
const templateCtx = { ...ctx, loopItem: itemName, scope: createScope(ctx.scope) };
|
|
748
|
+
const templateCtx = { ...ctx, loopItem: itemName, loopKey: key, scope: createScope(ctx.scope) };
|
|
709
749
|
const templateHtml = compileBody(bodyLines, templateCtx).trim();
|
|
710
750
|
|
|
711
751
|
let wrapperTag = "div";
|
|
@@ -868,7 +908,9 @@ function resolveBindingHtml(expr, ctx) {
|
|
|
868
908
|
|
|
869
909
|
const staticValue = lookupVar(ctx.scope, head);
|
|
870
910
|
if (staticValue.found) {
|
|
871
|
-
|
|
911
|
+
const resolved = getPath(staticValue.value, segs.slice(1));
|
|
912
|
+
if (Array.isArray(resolved)) return escapeHtml(resolved.join(", "));
|
|
913
|
+
return escapeHtml(resolved ?? "");
|
|
872
914
|
}
|
|
873
915
|
|
|
874
916
|
const key = resolveStateKey(head, ctx);
|
|
@@ -933,17 +975,41 @@ function parseAction(raw, ctx) {
|
|
|
933
975
|
if (increment) return { op: "inc", target: resolveTarget(increment[1]) };
|
|
934
976
|
const decrement = expression.match(/^([A-Za-z_$][\w$]*)--$/);
|
|
935
977
|
if (decrement) return { op: "dec", target: resolveTarget(decrement[1]) };
|
|
978
|
+
// Per-row: carry the current loop item into another list — `cart += product`.
|
|
979
|
+
// Only inside a reactive @loop; the runtime resolves the row from the DOM.
|
|
936
980
|
const add = expression.match(/^([A-Za-z_$][\w$]*)\s*\+=\s*(.+)$/);
|
|
937
981
|
if (add) {
|
|
982
|
+
const rhs = add[2].trim();
|
|
983
|
+
if (ctx.loopItem && rhs === ctx.loopItem) {
|
|
984
|
+
const target = resolveTarget(add[1]);
|
|
985
|
+
if (!Array.isArray(ctx.comp.state.get(target))) {
|
|
986
|
+
throw new Error(`Button action "${raw}" needs ${add[1]} to be a :state list (declare it "${add[1]} = []") in ${ctx.file}.`);
|
|
987
|
+
}
|
|
988
|
+
return { op: "append-row", target };
|
|
989
|
+
}
|
|
938
990
|
const target = resolveTarget(add[1]);
|
|
939
|
-
const value = parseActionLiteral(
|
|
991
|
+
const value = parseActionLiteral(rhs);
|
|
940
992
|
if (Array.isArray(ctx.comp.state.get(target))) return { op: "append", target, value };
|
|
941
993
|
if (typeof value === "number") return { op: "add", target, value };
|
|
942
994
|
throw new Error(`Unsupported button action "${raw}". += with non-number values requires a list state target.`);
|
|
943
995
|
}
|
|
996
|
+
// Per-row: remove the current row from the list being looped — `todos remove todo`.
|
|
997
|
+
const remove = expression.match(/^([A-Za-z_$][\w$]*)\s+remove\s+([A-Za-z_$][\w$]*)$/);
|
|
998
|
+
if (remove) {
|
|
999
|
+
if (!ctx.loopItem) {
|
|
1000
|
+
throw new Error(`Button action "${raw}" is only valid inside a reactive @loop in ${ctx.file}. Use: :button "Remove" -> list remove item`);
|
|
1001
|
+
}
|
|
1002
|
+
if (remove[2] !== ctx.loopItem) {
|
|
1003
|
+
throw new Error(`Button action "${raw}" must remove the loop item "${ctx.loopItem}" in ${ctx.file}, not "${remove[2]}".`);
|
|
1004
|
+
}
|
|
1005
|
+
if (!ctx.loopKey || resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
|
|
1006
|
+
throw new Error(`Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}.`);
|
|
1007
|
+
}
|
|
1008
|
+
return { op: "remove", target: ctx.loopKey };
|
|
1009
|
+
}
|
|
944
1010
|
const assign = expression.match(/^([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
|
|
945
1011
|
if (assign) return { op: "set", target: resolveTarget(assign[1]), value: parseActionLiteral(assign[2]) };
|
|
946
|
-
throw new Error(`Unsupported button action "${raw}". Supported actions: count++, count--, count += 1, items += "value", name = "value".`);
|
|
1012
|
+
throw new Error(`Unsupported button action "${raw}". Supported actions: count++, count--, count += 1, items += "value", name = "value", list remove item (in a loop), cart += item (in a loop).`);
|
|
947
1013
|
}
|
|
948
1014
|
|
|
949
1015
|
function parseActionLiteral(raw) {
|
package/src/runtime.js
CHANGED
|
@@ -132,6 +132,7 @@ function render() {
|
|
|
132
132
|
}
|
|
133
133
|
used.add(key);
|
|
134
134
|
fillItem(node, item);
|
|
135
|
+
node.__wdItem = item; // let per-row actions resolve which row was clicked
|
|
135
136
|
out.appendChild(node);
|
|
136
137
|
}
|
|
137
138
|
for (const [key, node] of existing) {
|
|
@@ -156,6 +157,14 @@ document.addEventListener("input", (event) => {
|
|
|
156
157
|
render();
|
|
157
158
|
});
|
|
158
159
|
|
|
160
|
+
// The clicked button's row: the nearest reconciled loop node carries its item.
|
|
161
|
+
function clickedRow(el) {
|
|
162
|
+
const row = el.closest("[data-wd-loop-key]");
|
|
163
|
+
const region = el.closest("[data-wd-loop]");
|
|
164
|
+
if (!row || !region) return null;
|
|
165
|
+
return { srcKey: region.getAttribute("data-wd-loop"), item: row.__wdItem };
|
|
166
|
+
}
|
|
167
|
+
|
|
159
168
|
document.addEventListener("click", (event) => {
|
|
160
169
|
const action = event.target.closest("[data-wd-action]");
|
|
161
170
|
if (!action) return;
|
|
@@ -169,6 +178,20 @@ document.addEventListener("click", (event) => {
|
|
|
169
178
|
if (op === "add") state[target] = Number(state[target] ?? 0) + Number(value);
|
|
170
179
|
if (op === "append") state[target] = [...(Array.isArray(state[target]) ? state[target] : []), value];
|
|
171
180
|
if (op === "set") state[target] = value;
|
|
181
|
+
if (op === "remove") {
|
|
182
|
+
const row = clickedRow(action);
|
|
183
|
+
if (row && row.srcKey) state[row.srcKey] = (Array.isArray(state[row.srcKey]) ? state[row.srcKey] : []).filter((x) => x !== row.item);
|
|
184
|
+
}
|
|
185
|
+
if (op === "append-row") {
|
|
186
|
+
const row = clickedRow(action);
|
|
187
|
+
// Clone the row so each appended line is a distinct object — otherwise adding
|
|
188
|
+
// the same source row twice yields two identical references and a later
|
|
189
|
+
// remove (filter by !== ref) would delete both lines.
|
|
190
|
+
if (row && row.item !== undefined) {
|
|
191
|
+
const copy = row.item && typeof row.item === "object" ? structuredClone(row.item) : row.item;
|
|
192
|
+
state[target] = [...(Array.isArray(state[target]) ? state[target] : []), copy];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
172
195
|
savePersisted();
|
|
173
196
|
render();
|
|
174
197
|
});
|