@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 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`. 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="…">`.
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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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.
@@ -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
@@ -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
- - **{ p.name }** — ${ p.price }
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
- const ctx = { file, context, stack: [...stack, real], scope, comp, sections, loopItem };
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]] = stripQuotes(match[2]);
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
- return escapeHtml(getPath(staticValue.value, segs.slice(1)) ?? "");
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(add[2]);
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
  });