@zvndev/darkmown 1.5.0 → 2.0.1

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.
Files changed (95) hide show
  1. package/AGENTS.md +18 -4
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +49 -19
  4. package/SECURITY.md +21 -18
  5. package/docs/cli.md +5 -1
  6. package/docs/spec-alignment.md +21 -7
  7. package/package.json +3 -2
  8. package/site/_/base.skin +8 -0
  9. package/site/_/base.wd +4 -0
  10. package/site/_/feature-card.wd +4 -0
  11. package/site/_/footer.wd +5 -1
  12. package/site/_/nav.wd +4 -0
  13. package/site/_/scoped-promo.wd +4 -0
  14. package/site/_/scoped-stat.wd +4 -0
  15. package/site/pages/404.wd +1 -0
  16. package/site/pages/app.wd +2 -1
  17. package/site/pages/blog/collections-and-pagination.skin +127 -0
  18. package/site/pages/blog/drafts-that-never-leak.skin +127 -0
  19. package/site/pages/blog/index.wd +1 -0
  20. package/site/pages/blog/shipping-feeds.skin +127 -0
  21. package/site/pages/blog/typed-frontmatter.skin +127 -0
  22. package/site/pages/blog/unfinished-thoughts.skin +127 -0
  23. package/site/pages/blog/zero-js-by-default.md +1 -1
  24. package/site/pages/blog/zero-js-by-default.skin +127 -0
  25. package/site/pages/carousel/index.wd +1 -0
  26. package/site/pages/compass/index.wd +1 -0
  27. package/site/pages/data.wd +3 -0
  28. package/site/pages/docs/-relative-note.wd +4 -0
  29. package/site/pages/docs/index.skin +27 -0
  30. package/site/pages/docs/index.wd +85 -14
  31. package/site/pages/folio/-style.wd +4 -0
  32. package/site/pages/folio/cart.wd +1 -0
  33. package/site/pages/folio/index.wd +1 -0
  34. package/site/pages/forge/index.wd +1 -0
  35. package/site/pages/highlight/index.wd +1 -0
  36. package/site/pages/index.js +19 -0
  37. package/site/pages/index.skin +46 -1
  38. package/site/pages/index.wd +6 -3
  39. package/site/pages/interactions/index.wd +1 -0
  40. package/site/pages/ledger/index.wd +1 -0
  41. package/site/pages/loops.wd +1 -0
  42. package/site/pages/media/index.wd +1 -0
  43. package/site/pages/pulse/index.wd +1 -0
  44. package/site/pages/reactive.wd +1 -0
  45. package/site/pages/scoped/index.wd +1 -0
  46. package/site/pages/showcase/index.wd +2 -1
  47. package/site/pages/store.wd +1 -0
  48. package/site/pages/theme.wd +1 -0
  49. package/src/builder.js +444 -65
  50. package/src/cli.js +96 -18
  51. package/src/compiler/actions.js +303 -0
  52. package/src/compiler/body.js +36 -24
  53. package/src/compiler/context.js +65 -6
  54. package/src/compiler/directives.js +31 -1470
  55. package/src/compiler/fetch.js +159 -0
  56. package/src/compiler/format.js +17 -4
  57. package/src/compiler/forms.js +456 -0
  58. package/src/compiler/frontmatter.js +10 -5
  59. package/src/compiler/includes.js +75 -4
  60. package/src/compiler/index.js +15 -3
  61. package/src/compiler/loops.js +85 -23
  62. package/src/compiler/markdown.js +65 -15
  63. package/src/compiler/media.js +133 -0
  64. package/src/compiler/page.js +88 -77
  65. package/src/compiler/state.js +168 -0
  66. package/src/compiler/structure.js +399 -0
  67. package/src/dev.js +76 -0
  68. package/src/headers.js +56 -17
  69. package/src/router.js +12 -1
  70. package/src/runtime.js +5 -3
  71. package/src/statics.js +63 -12
  72. package/src/templates/blog/README.md +13 -4
  73. package/src/templates/blog/site/_/nav.wd +7 -6
  74. package/src/templates/blog/site/pages/index.skin +10 -4
  75. package/src/templates/blog/site/pages/index.wd +11 -6
  76. package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
  77. package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
  78. package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
  79. package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
  80. package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
  81. package/src/templates/dashboard/site/_/nav.wd +7 -6
  82. package/src/templates/dashboard/site/pages/index.skin +7 -4
  83. package/src/templates/dashboard/site/pages/index.wd +2 -2
  84. package/src/templates/landing/site/_/nav.wd +7 -6
  85. package/src/templates/landing/site/pages/index.skin +13 -4
  86. package/src/templates/landing/site/pages/index.wd +22 -8
  87. package/src/templates/starter/site/_/nav.wd +8 -7
  88. package/src/templates/starter/site/pages/about.md +8 -2
  89. package/src/templates/starter/site/pages/about.skin +44 -0
  90. package/src/templates/starter/site/pages/index.skin +10 -6
  91. package/src/templates/starter/site/pages/index.wd +2 -2
  92. package/src/templates/store/site/_/nav.wd +7 -6
  93. package/src/templates/store/site/pages/index.skin +7 -4
  94. package/src/templates/store/site/pages/index.wd +2 -2
  95. package/src/templates/blog/site/_/posts.json +0 -16
package/src/cli.js CHANGED
@@ -8,7 +8,14 @@ import { handleApiRequest } from "./api-runner.js";
8
8
  import { buildSite } from "./builder.js";
9
9
  import { createPaths } from "./config.js";
10
10
  import { DEPLOY_TARGETS, deploy } from "./deploy.js";
11
- import { devClientPath, devClientScript, devEventsPath, injectDevClient } from "./dev.js";
11
+ import {
12
+ buildFailedPage,
13
+ createRebuildQueue,
14
+ devClientPath,
15
+ devClientScript,
16
+ devEventsPath,
17
+ injectDevClient
18
+ } from "./dev.js";
12
19
  import { discoverRoutes, isDraft, outputPathForRoute } from "./router.js";
13
20
  import { availableTemplates, initProject } from "./scaffold.js";
14
21
  import { contentType, resolvePublicFile, serve } from "./statics.js";
@@ -69,7 +76,16 @@ export async function run(argv, env = {}) {
69
76
  // `--drafts` includes `draft: true` pages in the build + feeds (staging);
70
77
  // the default production build excludes them at route discovery.
71
78
  const includeDrafts = argv.includes("--drafts");
72
- const result = buildSite(cwd, { target, includeDrafts });
79
+ // Dev-server plumbing: `--dep-map` writes the per-route dependency map;
80
+ // `--changed <path>` (repeatable, implies --dep-map) attempts an incremental
81
+ // rebuild of just the affected routes, with a full-rebuild fallback on any
82
+ // uncertainty. Not part of the public CLI surface.
83
+ const changed = flagValues(argv, "--changed");
84
+ const depMap = argv.includes("--dep-map") || changed.length > 0;
85
+ // Dev rebuilds pass this so the site_url/placeholder feed hints print once per
86
+ // session (from the initial in-process build) instead of on every rebuild.
87
+ const quietFeedHints = argv.includes("--quiet-feed-hints");
88
+ const result = buildSite(cwd, { target, includeDrafts, changed, depMap, quietFeedHints });
73
89
  log(buildSummary(result, path.relative(cwd, result.distRoot)));
74
90
  if (target === "cloudflare")
75
91
  log("Emitted dist/_worker.js for Cloudflare Pages api/* functions");
@@ -128,8 +144,6 @@ function startDevServer({ cwd, log, warn, error }) {
128
144
  const distRoot = path.join(cwd, "dist");
129
145
  /** @type {Set<import("node:http").ServerResponse>} */
130
146
  const clients = new Set();
131
- /** @type {ReturnType<typeof setTimeout> | undefined} */
132
- let timer;
133
147
  /** @type {string | undefined} Last build failure, replayed to clients on connect. */
134
148
  let lastBuildError;
135
149
 
@@ -138,20 +152,30 @@ function startDevServer({ cwd, log, warn, error }) {
138
152
  // see it on connect; the next successful rebuild clears it via `reload`.
139
153
  // Dev builds + serves `draft: true` pages (they're work-in-progress you want
140
154
  // to preview); the dev client stamps a visible banner so a draft is obvious.
155
+ // `depMap: true` writes the per-route dependency map that lets the rebuilds
156
+ // below be incremental for site/ content changes.
141
157
  try {
142
- buildSite(cwd, { includeDrafts: true });
158
+ buildSite(cwd, { includeDrafts: true, depMap: true });
143
159
  } catch (err) {
144
160
  lastBuildError = (err instanceof Error ? err.message : String(err)).trim();
145
- error(lastBuildError);
161
+ error(`✗ Initial build failed:\n${lastBuildError}`);
146
162
  }
147
163
 
148
164
  // Rebuild in a child process so changes to Darkmown's own src/ always load
149
165
  // fresh modules — an in-process rebuild would reuse the stale import cache.
150
- const rebuild = () => {
151
- clearTimeout(timer);
152
- timer = setTimeout(() => {
166
+ // A site/ change additionally passes the changed path(s), so the child can
167
+ // rebuild only the routes whose dependency graph contains them (the builder
168
+ // falls back to a full rebuild on any uncertainty). A src/ change — or an
169
+ // event with no attributable file — queues `null`, forcing the full rebuild.
170
+ // The queue serializes the children: every build read-modify-writes the
171
+ // dependency map, so overlapping children could persist a stale map and
172
+ // silently stop rebuilding affected routes.
173
+ const runBuild = (/** @type {string[]} */ changed) =>
174
+ new Promise((/** @type {(value?: void) => void} */ done) => {
175
+ const args = [cliPath, "build", "--drafts", "--dep-map", "--quiet-feed-hints"];
176
+ for (const file of changed) args.push("--changed", file);
153
177
  const started = performance.now();
154
- execFile(process.execPath, [cliPath, "build", "--drafts"], { cwd }, (err, stdout, stderr) => {
178
+ execFile(process.execPath, args, { cwd }, (err, stdout, stderr) => {
155
179
  if (err) {
156
180
  const message = (stderr || stdout || String(err)).trim();
157
181
  lastBuildError = message;
@@ -160,6 +184,7 @@ function startDevServer({ cwd, log, warn, error }) {
160
184
  clients,
161
185
  `event: builderror\ndata: ${JSON.stringify({ message: message.slice(0, 4000) })}\n\n`
162
186
  );
187
+ done();
163
188
  return;
164
189
  }
165
190
  lastBuildError = undefined;
@@ -167,16 +192,25 @@ function startDevServer({ cwd, log, warn, error }) {
167
192
  if (stderr.trim()) warn(stderr.trim());
168
193
  broadcast(clients, `event: reload\ndata: ${JSON.stringify({ elapsed })}\n\n`);
169
194
  log(`${stdout.trim().split("\n").at(-1)} (${elapsed}ms)`);
195
+ done();
170
196
  });
171
- }, 30);
172
- };
197
+ });
198
+ const queue = createRebuildQueue(runBuild);
173
199
 
174
200
  /** @type {import("node:fs").FSWatcher[]} */
175
201
  const watchers = [];
176
202
  for (const dir of ["site", "src"]) {
177
203
  const abs = path.join(cwd, dir);
178
204
  if (fs.existsSync(abs)) {
179
- watchers.push(fs.watch(abs, { recursive: true }, rebuild));
205
+ watchers.push(
206
+ fs.watch(abs, { recursive: true }, (_event, filename) => {
207
+ queue.change(
208
+ dir === "site" && filename
209
+ ? path.join(dir, filename.toString()).replaceAll(path.sep, "/")
210
+ : null
211
+ );
212
+ })
213
+ );
180
214
  }
181
215
  }
182
216
 
@@ -211,7 +245,7 @@ function startDevServer({ cwd, log, warn, error }) {
211
245
  // throw on either path (e.g. a stat/read error) becomes a clean 500.
212
246
  handleApiRequest({ apiDir, req, res })
213
247
  .then((handled) => {
214
- if (!handled) serveDev(distRoot, url, res, draftRoutes(cwd));
248
+ if (!handled) serveDev(distRoot, url, res, draftRoutes(cwd), () => lastBuildError);
215
249
  })
216
250
  .catch((err) => {
217
251
  res.writeHead(500, { "content-type": "text/plain" });
@@ -221,13 +255,19 @@ function startDevServer({ cwd, log, warn, error }) {
221
255
 
222
256
  return new Promise((resolve) => {
223
257
  server.listen(port, host, () => {
224
- log(`Darkmown dev server ready at http://${host}:${port}`);
258
+ // Never claim a clean start over a broken build: when the initial build
259
+ // failed, say so on the ready line and point at the fix-to-retry flow.
260
+ log(
261
+ lastBuildError
262
+ ? `Darkmown dev server ready at http://${host}:${port} — but the initial build FAILED (see the error above). Routes show it until you fix the file; rebuilds run on save.`
263
+ : `Darkmown dev server ready at http://${host}:${port}`
264
+ );
225
265
  log(`Live compiler watching site/ and src/`);
226
266
  resolve({
227
267
  command: "dev",
228
268
  server,
229
269
  close: () => {
230
- clearTimeout(timer);
270
+ queue.close();
231
271
  for (const watcher of watchers) watcher.close();
232
272
  for (const client of clients) client.end();
233
273
  clients.clear();
@@ -271,11 +311,23 @@ function startPreviewServer({ cwd, log, error }) {
271
311
  * @param {import("node:http").ServerResponse} res
272
312
  * @param {Set<string>} draftFiles Absolute dist HTML paths of draft pages — each
273
313
  * gets the dev-only draft banner injected (never written to disk).
314
+ * @param {() => string | undefined} buildError Reads the last recorded build
315
+ * failure (undefined once a build succeeds).
274
316
  * @returns {void}
275
317
  */
276
- function serveDev(distRoot, url, res, draftFiles) {
318
+ function serveDev(distRoot, url, res, draftFiles, buildError) {
277
319
  const file = resolvePublicFile(distRoot, url);
278
320
  if (!file || !fs.existsSync(file)) {
321
+ // An HTML route with no dist output while the last build FAILED is almost
322
+ // certainly unbuilt because of that failure — serve the compile error (the
323
+ // SSE overlay replays it too), not the misleading "hidden or not created"
324
+ // 404. Non-HTML assets and traversal-rejected URLs still 404 normally.
325
+ const failure = buildError();
326
+ if (failure && file?.endsWith(".html")) {
327
+ res.writeHead(500, { "content-type": "text/html; charset=utf-8" });
328
+ res.end(buildFailedPage(failure));
329
+ return;
330
+ }
279
331
  serve(distRoot, url, res);
280
332
  return;
281
333
  }
@@ -353,11 +405,19 @@ Authoring:
353
405
  * (home `site_url` set), the sitemap URL and RSS post counts — e.g.
354
406
  * `Built 14 routes, sitemap (14 urls), rss (6 posts) into dist`. When no
355
407
  * `site_url` is set the feed clauses are omitted (a hint was already warned).
356
- * @param {{ routes: unknown[], feeds: { sitemap: number | null, rss: number | null } }} result
408
+ * An incremental (dev `--changed`) build reports what it actually rebuilt —
409
+ * `Rebuilt 2 of 14 routes (/blog/, /blog/hello/) into dist` — naming the routes
410
+ * when there are few enough to read.
411
+ * @param {{ routes: unknown[], feeds: { sitemap: number | null, rss: number | null }, incremental?: { rebuilt: string[], total: number } }} result
357
412
  * @param {string} relativeRoot `dist` relative to cwd, for the message tail.
358
413
  * @returns {string}
359
414
  */
360
415
  export function buildSummary(result, relativeRoot) {
416
+ if (result.incremental) {
417
+ const { rebuilt, total } = result.incremental;
418
+ const shown = rebuilt.length > 0 && rebuilt.length <= 6 ? ` (${rebuilt.join(", ")})` : "";
419
+ return `Rebuilt ${rebuilt.length} of ${total} routes${shown} into ${relativeRoot}`;
420
+ }
361
421
  const parts = [`Built ${result.routes.length} routes`];
362
422
  if (result.feeds.sitemap !== null) parts.push(`sitemap (${result.feeds.sitemap} urls)`);
363
423
  if (result.feeds.rss !== null) parts.push(`rss (${result.feeds.rss} posts)`);
@@ -387,6 +447,24 @@ export function flagValue(argv, flag) {
387
447
  return undefined;
388
448
  }
389
449
 
450
+ /**
451
+ * Read every occurrence of a repeatable `--flag value` / `--flag=value` option
452
+ * out of an argv tail (e.g. the dev server passing one `--changed <path>` per
453
+ * file in a debounced batch).
454
+ * @param {string[]} argv The argv tail.
455
+ * @param {string} flag The flag name, e.g. `"--changed"`.
456
+ * @returns {string[]} The values, in argv order (empty when the flag is absent).
457
+ */
458
+ export function flagValues(argv, flag) {
459
+ /** @type {string[]} */
460
+ const values = [];
461
+ for (let i = 0; i < argv.length; i++) {
462
+ if (argv[i] === flag && i + 1 < argv.length) values.push(argv[++i]);
463
+ else if (argv[i].startsWith(`${flag}=`)) values.push(argv[i].slice(flag.length + 1));
464
+ }
465
+ return values;
466
+ }
467
+
390
468
  /**
391
469
  * Binary entry point: read argv, dispatch, and map failures to a clean
392
470
  * `✗`-prefixed line + exit code 1 — never a Node stack trace. The thrown Error
@@ -0,0 +1,303 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Actions: the `:button` action-expression parser (the validated `{ op, target,
3
+ // value }` vocabulary) and its consumers — `:button` itself plus the two
4
+ // action-driven markers `:effect` (run on state change) and `:every` (run on a
5
+ // timer).
6
+ //
7
+ // Every handler receives the 0-based line `index` so its malformed/invalid
8
+ // errors report `file:line` (via `at`) — matching the unclosed-block errors —
9
+ // while keeping the message text and `Use:` hints intact.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import { at } from "./context.js";
13
+ import {
14
+ escapeHtml,
15
+ resolveStateKey,
16
+ safeScriptJson,
17
+ stripQuotes,
18
+ validatePath
19
+ } from "./interpolation.js";
20
+
21
+ /**
22
+ * @typedef {import("./context.js").Ctx} Ctx
23
+ * @typedef {import("./context.js").Action} Action
24
+ */
25
+
26
+ /**
27
+ * @param {string} line
28
+ * @param {Ctx} ctx
29
+ * @param {number} index 0-based line index for `file:line` errors.
30
+ * @returns {string}
31
+ */
32
+ export function handleButton(line, ctx, index) {
33
+ const match = line.match(/^:button\s+"([^"]+)"\s*->\s*(.+)$/);
34
+ if (!match) throw new Error(`Malformed :button in ${at(ctx, index)}: ${line}`);
35
+ ctx.comp.assets.runtime = true;
36
+ const action = parseAction(match[2], ctx);
37
+ if (Array.isArray(action)) {
38
+ return `<button type="button" data-wd-actions="${escapeHtml(JSON.stringify(action))}">${escapeHtml(match[1])}</button>`;
39
+ }
40
+ const valueAttr =
41
+ action.value === undefined
42
+ ? ""
43
+ : ` data-wd-value="${escapeHtml(JSON.stringify(action.value))}"`;
44
+ return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
45
+ }
46
+
47
+ const EFFECT_USE =
48
+ "Use: :effect watchedState -> action[; action…] (actions use the :button vocabulary).";
49
+
50
+ /**
51
+ * Parse `:effect <watched> -> <actions>` into a zero-output marker the runtime
52
+ * watches: when `<watched>` state changes, it runs `<actions>` (the same `:button`
53
+ * action vocabulary, `;`-chained). For arbitrary side effects beyond `:computed`
54
+ * (derive state) and fetch deps (auto-refetch).
55
+ * @param {string} line
56
+ * @param {Ctx} ctx
57
+ * @param {number} index 0-based line index for `file:line` errors.
58
+ * @returns {string}
59
+ */
60
+ export function handleEffect(line, ctx, index) {
61
+ const match = line.match(/^:effect\s+([A-Za-z_$][\w$.]*)\s*->\s*(.+)$/);
62
+ if (!match) throw new Error(`Malformed :effect in ${at(ctx, index)}: ${line}. ${EFFECT_USE}`);
63
+ const segs = validatePath(match[1], ctx, EFFECT_USE);
64
+ const key = resolveStateKey(segs[0], ctx);
65
+ if (!key)
66
+ throw new Error(
67
+ `:effect watches unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`
68
+ );
69
+ ctx.comp.assets.runtime = true;
70
+ const watch = [key, ...segs.slice(1)].join(".");
71
+ const action = parseAction(match[2], ctx);
72
+ const actions = Array.isArray(action) ? action : [action];
73
+ return `<script type="application/json" data-wd-effect>${safeScriptJson({ watch, actions })}</script>`;
74
+ }
75
+
76
+ const EVERY_USE =
77
+ "Use: :every <duration> -> action[; action…] — duration like 5s, 500ms, or 2m (actions use the :button vocabulary).";
78
+
79
+ /**
80
+ * Parse a duration token into milliseconds: `<int>[ms|s|m]`, defaulting to ms.
81
+ * @param {string} raw
82
+ * @returns {number | null}
83
+ */
84
+ function parseDuration(raw) {
85
+ const match = raw.trim().match(/^(\d+)(ms|s|m)?$/);
86
+ if (!match) return null;
87
+ const n = Number(match[1]);
88
+ return match[2] === "s" ? n * 1000 : match[2] === "m" ? n * 60000 : n;
89
+ }
90
+
91
+ /**
92
+ * Parse `:every <duration> -> <actions>` into a marker the runtime drives on a
93
+ * timer: every `<duration>` it runs `<actions>` (the same `:button` vocabulary,
94
+ * `;`-chained), and the interval auto-pauses while the tab is hidden. The one time
95
+ * primitive — behind live polling (`:every 5s -> board refetch`), clocks and
96
+ * countdowns (`:every 1s -> seconds++`), and slideshow autoplay (`:every 4s -> slide++`).
97
+ * @param {string} line
98
+ * @param {Ctx} ctx
99
+ * @param {number} index 0-based line index for `file:line` errors.
100
+ * @returns {string}
101
+ */
102
+ export function handleEvery(line, ctx, index) {
103
+ const match = line.match(/^:every\s+(\S+)\s*->\s*(.+)$/);
104
+ if (!match) throw new Error(`Malformed :every in ${at(ctx, index)}: ${line}. ${EVERY_USE}`);
105
+ const ms = parseDuration(match[1]);
106
+ if (ms == null || ms <= 0) {
107
+ throw new Error(
108
+ `:every duration "${match[1]}" is not valid in ${at(ctx, index)}. ${EVERY_USE}`
109
+ );
110
+ }
111
+ ctx.comp.assets.runtime = true;
112
+ const action = parseAction(match[2], ctx);
113
+ const actions = Array.isArray(action) ? action : [action];
114
+ return `<script type="application/json" data-wd-every>${safeScriptJson({ ms, actions })}</script>`;
115
+ }
116
+
117
+ const ACTION_USE =
118
+ 'Use: name++, name--, n += k, n -= k, name = v, flag toggle, list append/prepend v, list toggle v, list remove v, x clear, obj merge other, obj delete key, name reset — chain with ";".';
119
+
120
+ /**
121
+ * Parse a `:button` action expression into a validated `{ op, target, value }`,
122
+ * or — when `;`-separated — an ordered array of them applied in sequence.
123
+ * @param {string} raw
124
+ * @param {Ctx} ctx
125
+ * @returns {Action | Action[]}
126
+ */
127
+ function parseAction(raw, ctx) {
128
+ const parts = raw
129
+ .split(";")
130
+ .map((p) => p.trim())
131
+ .filter(Boolean);
132
+ if (parts.length > 1) return parts.map((part) => parseSingleAction(part, raw, ctx));
133
+ return parseSingleAction(raw.trim(), raw, ctx);
134
+ }
135
+
136
+ /**
137
+ * Parse one (non-`;`) action expression into a validated `{ op, target, value }`.
138
+ * Targets may be dotted paths (`cart.count`); a bare name is a 1-segment path.
139
+ * @param {string} expression
140
+ * @param {string} raw The full original action source (for error context).
141
+ * @param {Ctx} ctx
142
+ * @returns {Action}
143
+ */
144
+ function parseSingleAction(expression, raw, ctx) {
145
+ const PATH = "[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*";
146
+ /** @param {string} name @returns {string} */
147
+ const resolveTarget = (name) => {
148
+ const segs = validatePath(name, ctx, ACTION_USE);
149
+ const key = resolveStateKey(segs[0], ctx);
150
+ if (!key) {
151
+ throw new Error(
152
+ `Button action targets unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`
153
+ );
154
+ }
155
+ return [key, ...segs.slice(1)].join(".");
156
+ };
157
+
158
+ const increment = expression.match(new RegExp(`^(${PATH})\\+\\+$`));
159
+ if (increment) return { op: "inc", target: resolveTarget(increment[1]) };
160
+ const decrement = expression.match(new RegExp(`^(${PATH})--$`));
161
+ if (decrement) return { op: "dec", target: resolveTarget(decrement[1]) };
162
+ const sub = expression.match(new RegExp(`^(${PATH})\\s*-=\\s*(.+)$`));
163
+ if (sub)
164
+ return { op: "sub", target: resolveTarget(sub[1]), value: parseActionLiteral(sub[2], ctx) };
165
+ // Per-row: carry the current loop item into another list — `cart += product`.
166
+ // Only inside a reactive @loop; the runtime resolves the row from the DOM.
167
+ const add = expression.match(new RegExp(`^(${PATH})\\s*\\+=\\s*(.+)$`));
168
+ if (add) {
169
+ const rhs = add[2].trim();
170
+ if (ctx.loopItem && rhs === ctx.loopItem) {
171
+ const target = resolveTarget(add[1]);
172
+ if (!Array.isArray(ctx.comp.state.get(target))) {
173
+ throw new Error(
174
+ `Button action "${raw}" needs ${add[1]} to be a :state list (declare it "${add[1]} = []") in ${ctx.file}.`
175
+ );
176
+ }
177
+ return { op: "append-row", target };
178
+ }
179
+ const target = resolveTarget(add[1]);
180
+ const value = parseActionLiteral(rhs, ctx);
181
+ if (Array.isArray(ctx.comp.state.get(target))) return { op: "append", target, value };
182
+ if (typeof value === "number") return { op: "add", target, value };
183
+ throw new Error(
184
+ `Unsupported button action "${raw}" in ${ctx.file}. += with non-number values requires a list state target — declare it "${add[1]} = []".`
185
+ );
186
+ }
187
+ // `flag toggle` (no operand) → boolean flip; `list toggle v` → member-toggle.
188
+ const toggle = expression.match(new RegExp(`^(${PATH})\\s+toggle(?:\\s+(.+))?$`));
189
+ if (toggle) {
190
+ const target = resolveTarget(toggle[1]);
191
+ if (toggle[2] === undefined) return { op: "toggle", target };
192
+ return { op: "member-toggle", target, value: parseActionLiteral(toggle[2], ctx) };
193
+ }
194
+ const prepend = expression.match(new RegExp(`^(${PATH})\\s+(?:append|prepend)\\s+(.+)$`));
195
+ if (prepend) {
196
+ const op = expression.includes(" prepend ") ? "prepend" : "append";
197
+ return { op, target: resolveTarget(prepend[1]), value: parseActionLiteral(prepend[2], ctx) };
198
+ }
199
+ // Per-row: remove the current row from the list being looped — `todos remove todo`.
200
+ // Disambiguation: operand IS the loop item name → `remove` (current row);
201
+ // otherwise the operand is a value → `remove-value`.
202
+ const remove = expression.match(new RegExp(`^(${PATH})\\s+remove\\s+(.+)$`));
203
+ if (remove) {
204
+ const operand = remove[2].trim();
205
+ if (ctx.loopItem && operand === ctx.loopItem) {
206
+ // A nested (item-relative) loop has a loop item but no top-level state key —
207
+ // its source is a path off the outer row, which the runtime's row-remove
208
+ // can't target. Fail loud with the honest workaround.
209
+ if (!ctx.loopKey) {
210
+ throw new Error(
211
+ `Button action "${raw}" can't delete a row of a nested (item-relative) loop in ${ctx.file}. Per-row "remove" needs a top-level :state/:store list; carry the row into one (cart += ${ctx.loopItem}) and remove it there.`
212
+ );
213
+ }
214
+ if (resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
215
+ throw new Error(
216
+ `Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}.`
217
+ );
218
+ }
219
+ return { op: "remove", target: ctx.loopKey };
220
+ }
221
+ return {
222
+ op: "remove-value",
223
+ target: resolveTarget(remove[1]),
224
+ value: parseActionLiteral(operand, ctx)
225
+ };
226
+ }
227
+ const clear = expression.match(new RegExp(`^(${PATH})\\s+clear$`));
228
+ if (clear) return { op: "clear", target: resolveTarget(clear[1]) };
229
+ const merge = expression.match(new RegExp(`^(${PATH})\\s+merge\\s+(.+)$`));
230
+ if (merge)
231
+ return {
232
+ op: "merge",
233
+ target: resolveTarget(merge[1]),
234
+ value: parseMergeOperand(merge[2], ctx)
235
+ };
236
+ const del = expression.match(new RegExp(`^(${PATH})\\s+delete\\s+(.+)$`));
237
+ if (del)
238
+ return { op: "delete", target: resolveTarget(del[1]), value: parseActionLiteral(del[2], ctx) };
239
+ const reset = expression.match(new RegExp(`^(${PATH})\\s+reset$`));
240
+ if (reset) return { op: "reset", target: resolveTarget(reset[1]) };
241
+ // `name refetch` re-invokes the matching :fetch node. The target is the fetch
242
+ // key (bare name); the runtime finds the [data-wd-fetch-key] node and re-runs.
243
+ const refetch = expression.match(new RegExp(`^(${PATH})\\s+refetch$`));
244
+ if (refetch) return { op: "refetch", target: resolveTarget(refetch[1]) };
245
+ const assign = expression.match(new RegExp(`^(${PATH})\\s*=\\s*(.+)$`));
246
+ if (assign)
247
+ return {
248
+ op: "set",
249
+ target: resolveTarget(assign[1]),
250
+ value: parseActionLiteral(assign[2], ctx)
251
+ };
252
+ throw new Error(`Unsupported button action "${raw}" in ${ctx.file}. ${ACTION_USE}`);
253
+ }
254
+
255
+ /**
256
+ * `merge` operand: either an inline JSON object literal, or a state/store key
257
+ * name (emitted as a string the runtime resolves via getPath).
258
+ * @param {string} raw
259
+ * @param {Ctx} ctx
260
+ * @returns {unknown}
261
+ */
262
+ function parseMergeOperand(raw, ctx) {
263
+ const value = raw.trim();
264
+ if (/^[A-Za-z_$][\w$]*$/.test(value)) {
265
+ const key = resolveStateKey(value, ctx);
266
+ if (!key)
267
+ throw new Error(
268
+ `Button action merge targets unknown state "${value}" in ${ctx.file}. Declare it first with :state ${value} = ...`
269
+ );
270
+ return key;
271
+ }
272
+ const parsed = parseActionLiteral(value, ctx);
273
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
274
+ throw new Error(
275
+ `Unsupported merge operand "${raw}" in ${ctx.file}. Use: obj merge other (a state key or an inline {…} object).`
276
+ );
277
+ }
278
+
279
+ /**
280
+ * @param {string} raw
281
+ * @param {Ctx} ctx
282
+ * @returns {unknown}
283
+ */
284
+ function parseActionLiteral(raw, ctx) {
285
+ const value = raw.trim();
286
+ if (/^["'].*["']$/.test(value)) return stripQuotes(value);
287
+ if (value === "true") return true;
288
+ if (value === "false") return false;
289
+ if (value === "null") return null;
290
+ if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
291
+ if (/^[[{]/.test(value)) {
292
+ try {
293
+ return JSON.parse(value);
294
+ } catch {
295
+ throw new Error(
296
+ `Unsupported action literal "${raw}" in ${ctx.file}. Use: a quoted string, number, boolean, null, or valid JSON.`
297
+ );
298
+ }
299
+ }
300
+ throw new Error(
301
+ `Unsupported action literal "${raw}" in ${ctx.file}. Use: a quoted string, number, boolean, null, or valid JSON.`
302
+ );
303
+ }