@zvndev/darkmown 1.0.0 → 1.2.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +64 -3
  3. package/docs/cli.md +46 -16
  4. package/docs/spec-alignment.md +16 -3
  5. package/package.json +2 -2
  6. package/site/_/roadmap.json +3 -1
  7. package/site/pages/app.wd +1 -1
  8. package/site/pages/data.wd +2 -2
  9. package/site/pages/docs/index.wd +22 -0
  10. package/site/pages/folio/cart.wd +2 -2
  11. package/site/pages/forge/index.wd +1 -1
  12. package/site/pages/interactions/index.skin +113 -0
  13. package/site/pages/interactions/index.wd +86 -0
  14. package/site/pages/ledger/index.wd +10 -1
  15. package/src/api-runner.js +219 -0
  16. package/src/behaviors/carousel.js +153 -0
  17. package/src/behaviors/sortable.js +175 -0
  18. package/src/builder.js +199 -4
  19. package/src/cli.js +79 -54
  20. package/src/compiler/body.js +78 -3
  21. package/src/compiler/context.js +12 -1
  22. package/src/compiler/directives.js +123 -0
  23. package/src/compiler/interpolation.js +15 -14
  24. package/src/compiler/loops.js +57 -8
  25. package/src/compiler/page.js +9 -3
  26. package/src/deploy.js +174 -0
  27. package/src/headers.js +4 -0
  28. package/src/runtime.js +3 -1
  29. package/src/scaffold.js +79 -147
  30. package/src/templates/blog/README.md +25 -0
  31. package/src/templates/blog/site/_/nav.wd +6 -0
  32. package/src/templates/blog/site/_/posts.json +16 -0
  33. package/src/templates/blog/site/pages/index.skin +51 -0
  34. package/src/templates/blog/site/pages/index.wd +24 -0
  35. package/src/templates/blog/site/pages/posts/hello-darkmown.md +16 -0
  36. package/src/templates/blog/site/pages/posts/markdown-native.md +20 -0
  37. package/src/templates/dashboard/README.md +25 -0
  38. package/src/templates/dashboard/api/metrics.js +15 -0
  39. package/src/templates/dashboard/site/_/nav.wd +6 -0
  40. package/src/templates/dashboard/site/pages/index.skin +74 -0
  41. package/src/templates/dashboard/site/pages/index.wd +75 -0
  42. package/src/templates/landing/README.md +25 -0
  43. package/src/templates/landing/site/_/nav.wd +6 -0
  44. package/src/templates/landing/site/pages/index.skin +140 -0
  45. package/src/templates/landing/site/pages/index.wd +54 -0
  46. package/src/templates/starter/README.md +32 -0
  47. package/src/templates/starter/site/_/nav.wd +7 -0
  48. package/src/templates/starter/site/pages/about.md +10 -0
  49. package/src/templates/starter/site/pages/index.skin +47 -0
  50. package/src/templates/starter/site/pages/index.wd +27 -0
  51. package/src/templates/store/README.md +28 -0
  52. package/src/templates/store/api/checkout.js +18 -0
  53. package/src/templates/store/site/_/nav.wd +6 -0
  54. package/src/templates/store/site/_/products.json +8 -0
  55. package/src/templates/store/site/pages/index.skin +100 -0
  56. package/src/templates/store/site/pages/index.wd +83 -0
@@ -578,6 +578,129 @@ export function handleBind(line, ctx, index) {
578
578
  return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
579
579
  }
580
580
 
581
+ /**
582
+ * `:slider name [= value] [min=N] [max=N] [step=N] [aria-label="…"] [persist]` — a
583
+ * range input two-way bound to a NUMBER :state. With `= value` it declares the state
584
+ * inline (seeding it numeric) and may `persist`; without `=` it binds to an already-
585
+ * declared :state. Pure compile-time: it reuses the runtime's input binding (range
586
+ * values coerce to Number), so it ships NO behavior module.
587
+ * @param {string} line
588
+ * @param {Ctx} ctx
589
+ * @param {number} index 0-based line index for `file:line` errors.
590
+ * @returns {string}
591
+ */
592
+ export function handleSlider(line, ctx, index) {
593
+ const head = line.match(/^:slider\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
594
+ if (!head)
595
+ throw new Error(
596
+ `Malformed :slider in ${at(ctx.file, index)}: ${line}. Use: :slider volume = 50 min=0 max=100 step=1`
597
+ );
598
+ const name = head[1];
599
+ let rest = head[2].trim();
600
+
601
+ /** @type {string | undefined} */
602
+ let initialRaw;
603
+ if (rest.startsWith("=")) {
604
+ const valueMatch = rest.match(/^=\s*(\S+)\s*(.*)$/);
605
+ if (!valueMatch)
606
+ throw new Error(
607
+ `Malformed :slider initial value in ${at(ctx.file, index)}: ${line}. Use: :slider ${name} = 50`
608
+ );
609
+ initialRaw = valueMatch[1];
610
+ rest = valueMatch[2].trim();
611
+ }
612
+
613
+ let min = "0";
614
+ let max = "100";
615
+ let step = "1";
616
+ /** @type {string | undefined} */
617
+ let ariaLabel;
618
+ let persist = false;
619
+ for (const token of rest.matchAll(/([A-Za-z-]+)=("[^"]*"|\S+)|(\bpersist\b)/g)) {
620
+ if (token[3]) {
621
+ persist = true;
622
+ continue;
623
+ }
624
+ const value = stripQuotes(token[2]);
625
+ if (token[1] === "min") min = value;
626
+ else if (token[1] === "max") max = value;
627
+ else if (token[1] === "step") step = value;
628
+ else if (token[1] === "aria-label") ariaLabel = value;
629
+ else throw new Error(`Unknown :slider attribute "${token[1]}" in ${at(ctx.file, index)}`);
630
+ }
631
+ for (const [label, raw] of [
632
+ ["min", min],
633
+ ["max", max],
634
+ ["step", step]
635
+ ]) {
636
+ if (!/^-?\d+(?:\.\d+)?$/.test(raw))
637
+ throw new Error(`:slider ${label} must be a number in ${at(ctx.file, index)}: ${raw}`);
638
+ }
639
+
640
+ ctx.comp.assets.runtime = true;
641
+ /** @type {string} */
642
+ let key;
643
+ let seed = "";
644
+ if (initialRaw !== undefined) {
645
+ const value = parseStateValue(initialRaw, at(ctx.file, index));
646
+ if (typeof value !== "number")
647
+ throw new Error(
648
+ `:slider ${name} initial value must be a number in ${at(ctx.file, index)}: ${initialRaw}`
649
+ );
650
+ key = declareState(name, value, ctx);
651
+ const persistAttr = persist ? ` data-wd-persist="${key}"` : "";
652
+ seed = `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
653
+ } else {
654
+ if (persist)
655
+ throw new Error(
656
+ `:slider persist only applies when declaring state inline (:slider ${name} = 0 … persist) in ${at(ctx.file, index)}`
657
+ );
658
+ const resolved = resolveStateKey(name, ctx);
659
+ if (!resolved)
660
+ throw new Error(
661
+ `:slider ${name} in ${ctx.file} has no matching state. Declare it: :slider ${name} = 0 min=0 max=100`
662
+ );
663
+ key = resolved;
664
+ }
665
+
666
+ const initial = ctx.comp.state.get(key);
667
+ const valueAttr =
668
+ initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
669
+ const ariaAttr = ` aria-label="${escapeHtml(ariaLabel || humanizeName(name))}"`;
670
+ return `${seed}<input type="range" data-wd-bind-input="${key}" min="${escapeHtml(min)}" max="${escapeHtml(max)}" step="${escapeHtml(step)}"${valueAttr}${ariaAttr}>`;
671
+ }
672
+
673
+ /**
674
+ * `:carousel [autoplay=N]` … `:endcarousel` — a horizontally scroll-snapping
675
+ * carousel. Contract: each DIRECT child element of the track is one slide, so put
676
+ * each slide in its own block (e.g. a `::: slide` container) and give that block
677
+ * the slide sizing in your skin — loose prose lines would each count as a slide.
678
+ * Registers the `carousel` behavior (prev/next, dot nav, optional autoplay, mouse
679
+ * drag); native CSS scroll-snap + the page skin handle layout and touch swipe.
680
+ * `autoplay` is suppressed under `prefers-reduced-motion`. No runtime required.
681
+ * @param {string} line
682
+ * @param {string[]} bodyLines
683
+ * @param {Ctx} ctx
684
+ * @param {number} index 0-based line index for `file:line` errors.
685
+ * @returns {string}
686
+ */
687
+ export function handleCarousel(line, bodyLines, ctx, index) {
688
+ const match = line.match(/^:carousel\s*(.*)$/);
689
+ const rest = (match?.[1] || "").trim();
690
+ let autoplayAttr = "";
691
+ if (rest) {
692
+ const auto = rest.match(/^autoplay=(\d+)$/);
693
+ if (!auto)
694
+ throw new Error(
695
+ `Malformed :carousel in ${at(ctx.file, index)}: ${line}. Use: :carousel [autoplay=3000] … :endcarousel`
696
+ );
697
+ autoplayAttr = ` data-wd-carousel-autoplay="${auto[1]}"`;
698
+ }
699
+ ctx.comp.assets.behaviors.add("carousel");
700
+ const inner = compileBody(bodyLines, ctx);
701
+ return `<div class="wd-carousel" data-wd-carousel${autoplayAttr}>\n<div class="wd-carousel-track" data-wd-carousel-track>\n${inner}\n</div>\n</div>`;
702
+ }
703
+
581
704
  /**
582
705
  * @param {string} line
583
706
  * @param {Ctx} ctx
@@ -119,10 +119,12 @@ export function safeScriptJson(value) {
119
119
  }
120
120
 
121
121
  /**
122
- * Parse a single-line `:state`/`:store`/`:theme` value: quoted string, boolean,
123
- * null, number, or JSON literal — falling back to the bare string. A value that
124
- * opens a JSON array/object but won't parse throws (almost always a multi-line
125
- * literal, which these single-line directives can't express).
122
+ * Parse a `:state`/`:store`/`:theme` value: quoted string, boolean, null, number,
123
+ * or JSON literal — falling back to the bare string. Multi-line array/object
124
+ * literals are joined into one line before reaching here (see `joinValueDirective`
125
+ * in body.js), so a value that opens a `[`/`{` but won't parse is genuinely
126
+ * unbalanced (a missing `]`/`}`) and throws, rather than being stored verbatim as
127
+ * the partial string `"["` — the silent-corruption footgun this guard replaces.
126
128
  * @param {string} raw
127
129
  * @param {string} [where] `file:line` for the error message.
128
130
  * @returns {unknown}
@@ -137,18 +139,17 @@ export function parseStateValue(raw, where) {
137
139
  try {
138
140
  return JSON.parse(value);
139
141
  } catch {
140
- // A value that opens a JSON array/object but won't parse is almost always a
141
- // multi-line literal — :state/:store/:theme take a single-line value, so the
142
- // unterminated "[" used to get stored verbatim as the string "[" and silently
143
- // break the page. Fail loudly with the fix instead. Quote it to keep literal
144
- // brackets (e.g. `= "[draft]"`).
145
- if (/^[[{]/.test(value))
142
+ if (/^[[{]/.test(value)) {
143
+ const open = value[0];
144
+ const close = open === "[" ? "]" : "}";
145
+ const shown = value.length > 60 ? `${value.slice(0, 57)}…` : value;
146
146
  throw new Error(
147
- `Invalid value ${value}${where ? ` in ${where}` : ""}: this looks like the start of a ` +
148
- `multi-line array/object, but :state/:store values must fit on one line. ` +
149
- `Put the whole literal on one line, or quote it for literal text. ` +
150
- `Use: name = [{"id":1,"label":"…"}]`
147
+ `Unbalanced JSON value "${shown}"${where ? ` in ${where}` : ""}: this opens "${open}" but ` +
148
+ `never closes it. :state/:store/:theme accept a multi-line array/object, but the literal ` +
149
+ `must balance (a "${close}" is missing) with no blank lines inside it. ` +
150
+ `Quote it for literal bracket text (e.g. = "[draft]").`
151
151
  );
152
+ }
152
153
  return value;
153
154
  }
154
155
  }
@@ -29,15 +29,16 @@ import { compilePredicate, evalPredicate } from "./predicates.js";
29
29
  */
30
30
 
31
31
  // The fixed corrective suggestion shown for any malformed @loop header.
32
- const LOOP_USAGE = "Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N]";
32
+ const LOOP_USAGE =
33
+ "Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [sortable]";
33
34
 
34
35
  /**
35
36
  * Parse the optional clause tail of a `@loop` header in FIXED order:
36
- * `[where P] [sort by key [asc|desc]] [reverse] [offset N] [limit N]`.
37
+ * `[where P] [sort by key [asc|desc]] [reverse] [offset N] [limit N] [sortable]`.
37
38
  * @param {string} tail Everything after `@loop src into item`.
38
39
  * @param {string} itemName
39
40
  * @param {Ctx} ctx
40
- * @returns {{ where: string|null, sort: import("./context.js").LoopOpts["sort"], reverse: boolean, offset: NumArg|null, limit: NumArg|null, refsState: boolean }}
41
+ * @returns {{ where: string|null, sort: import("./context.js").LoopOpts["sort"], reverse: boolean, offset: NumArg|null, limit: NumArg|null, refsState: boolean, sortable: boolean }}
41
42
  */
42
43
  function parseLoopClauses(tail, itemName, ctx) {
43
44
  // Peel clauses off the END in reverse fixed-order (limit, offset, reverse,
@@ -51,11 +52,23 @@ function parseLoopClauses(tail, itemName, ctx) {
51
52
  /** @type {NumArg|null} */ let offset = null;
52
53
  /** @type {NumArg|null} */ let limit = null;
53
54
  let refsState = false;
55
+ let sortable = false;
54
56
  /** @returns {never} */
55
57
  const bad = () => {
56
58
  throw new Error(`Malformed @loop clause in ${ctx.file}: "${tail.trim()}". ${LOOP_USAGE}`);
57
59
  };
58
60
 
61
+ // `sortable` is a position-independent flag (drag-reorder); peel it wherever it
62
+ // sits so the rest of the fixed-order parse is unaffected. handleLoop rejects it
63
+ // when combined with where/sort/reverse/offset/limit.
64
+ const sm = s.match(/(^|\s)sortable(?=\s|$)/);
65
+ if (sm) {
66
+ sortable = true;
67
+ s = (s.slice(0, sm.index) + s.slice((sm.index ?? 0) + sm[0].length))
68
+ .replace(/\s+/g, " ")
69
+ .trim();
70
+ }
71
+
59
72
  let m = s.match(/(^|\s)limit\s+(\d+|[A-Za-z_$][\w$]*)$/);
60
73
  if (m) {
61
74
  limit = parseNumArg(m[2], ctx);
@@ -96,7 +109,7 @@ function parseLoopClauses(tail, itemName, ctx) {
96
109
  // predicate means a clause was written before `where` finished (wrong order).
97
110
  if (/(^|\s)(sort\s+by\s|reverse(\s|$))/.test(where)) bad();
98
111
  }
99
- return { where, sort, reverse, offset, limit, refsState };
112
+ return { where, sort, reverse, offset, limit, refsState, sortable };
100
113
  }
101
114
 
102
115
  /**
@@ -193,8 +206,27 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
193
206
  const itemName = match[2];
194
207
  const clauses = match[3]
195
208
  ? parseLoopClauses(match[3], itemName, ctx)
196
- : { where: null, sort: null, reverse: false, offset: null, limit: null, refsState: false };
209
+ : {
210
+ where: null,
211
+ sort: null,
212
+ reverse: false,
213
+ offset: null,
214
+ limit: null,
215
+ refsState: false,
216
+ sortable: false
217
+ };
197
218
  const where = clauses.where ? compilePredicate(clauses.where, itemName, ctx) : null;
219
+ // `sortable` reorders the list directly, so it can't combine with a clause that
220
+ // re-derives the visible order (where/sort/reverse) or shows a partial view
221
+ // (offset/limit) — those would break the 1:1 DOM↔array index mapping.
222
+ if (
223
+ clauses.sortable &&
224
+ (where || clauses.sort || clauses.reverse || clauses.offset || clauses.limit)
225
+ ) {
226
+ throw new Error(
227
+ `@loop sortable cannot combine with where/sort/reverse/offset/limit in ${ctx.file}. ${LOOP_USAGE}`
228
+ );
229
+ }
198
230
  /** @type {LoopOpts} */
199
231
  const opts = {
200
232
  where,
@@ -203,7 +235,8 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
203
235
  offset: clauses.offset,
204
236
  limit: clauses.limit,
205
237
  empty: emptyLines,
206
- clauseRefsState: clauses.refsState
238
+ clauseRefsState: clauses.refsState,
239
+ sortable: clauses.sortable
207
240
  };
208
241
 
209
242
  if (
@@ -242,6 +275,11 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
242
275
  // Inside a reactive loop, `@loop <outerItem>.<path> into x` loops a field of
243
276
  // the enclosing row item — an ITEM-RELATIVE loop the runtime fills per row.
244
277
  if (ctx.loopItem && segs[0] === ctx.loopItem && segs.length > 1) {
278
+ if (opts.sortable) {
279
+ throw new Error(
280
+ `@loop sortable is not supported on a nested (item-relative) loop in ${ctx.file}. ${LOOP_USAGE}`
281
+ );
282
+ }
245
283
  return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, ctx, opts);
246
284
  }
247
285
  }
@@ -298,6 +336,11 @@ function pipelineRows(rows, where, opts, ctx) {
298
336
  * @returns {string}
299
337
  */
300
338
  function loopOverData(rows, itemName, bodyLines, ctx, opts) {
339
+ if (opts.sortable) {
340
+ throw new Error(
341
+ `@loop sortable requires a :state or :store list to reorder, not a JSON file or in-scope value, in ${ctx.file}. ${LOOP_USAGE}`
342
+ );
343
+ }
301
344
  // Reactive when the where reads state OR an offset/limit references a state key.
302
345
  const reactive = (opts.where && opts.where.refsState) || opts.clauseRefsState;
303
346
  if (reactive) return reactiveLoop(null, itemName, bodyLines, ctx, { ...opts, data: rows });
@@ -396,7 +439,8 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
396
439
  ? { kind: "literal", value: num(limit) ?? allRows.length }
397
440
  : limit,
398
441
  empty: null,
399
- clauseRefsState: false
442
+ clauseRefsState: false,
443
+ sortable: false
400
444
  },
401
445
  ctx
402
446
  );
@@ -421,9 +465,14 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
421
465
  ? compileBody(emptyLines, { ...ctx, loopMeta: true }).trim()
422
466
  : initial;
423
467
 
468
+ // `sortable` tags the region for the drag-reorder behavior (emitted only here)
469
+ // and points it at the state key whose list it rewrites on drop.
470
+ const sortable = opts?.sortable || false;
471
+ if (sortable && key) ctx.comp.assets.behaviors.add("sortable");
424
472
  const attrs =
425
473
  loopClauseAttrs({ where, sort, reverse, offset, limit }) +
426
- (baked ? ` data-wd-loop-data="${escapeHtml(JSON.stringify(baked))}"` : "");
474
+ (baked ? ` data-wd-loop-data="${escapeHtml(JSON.stringify(baked))}"` : "") +
475
+ (sortable && key ? ` data-wd-sortable="${escapeHtml(key)}"` : "");
427
476
  return `<div data-wd-loop="${escapeHtml(key || "")}"${attrs}><template data-wd-loop-template>${itemTemplate}</template>${emptyTemplate}<${wrapperTag} data-wd-loop-out>${initialOut}</${wrapperTag}></div>`;
428
477
  }
429
478
 
@@ -61,9 +61,15 @@ export function compilePage(file, context) {
61
61
  const cssLinks = [...compiled.assets.skins]
62
62
  .map((href) => `<link rel="stylesheet" href="${href}">`)
63
63
  .join("\n");
64
- const scriptSrcs = compiled.assets.runtime
65
- ? ["/__wd/runtime.js", ...compiled.assets.scripts]
66
- : [...compiled.assets.scripts];
64
+ // Pay-for-what-you-use behavior modules (slider is compile-time and never here;
65
+ // sortable/carousel each emit one) load after the runtime — sortable depends on
66
+ // `window.wd` — and before any colocated page script.
67
+ const behaviorSrcs = [...compiled.assets.behaviors].map((name) => `/__wd/behaviors/${name}.js`);
68
+ const scriptSrcs = [
69
+ ...(compiled.assets.runtime ? ["/__wd/runtime.js"] : []),
70
+ ...behaviorSrcs,
71
+ ...compiled.assets.scripts
72
+ ];
67
73
  const scripts = scriptSrcs
68
74
  .map((src) => `<script type="module" src="${src}"></script>`)
69
75
  .join("\n");
package/src/deploy.js ADDED
@@ -0,0 +1,174 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { buildSite } from "./builder.js";
5
+ import { BASE_SECURITY_HEADERS, REACTIVE_CSP } from "./headers.js";
6
+
7
+ /** The platforms `darkmown deploy` knows how to drive. */
8
+ export const DEPLOY_TARGETS = ["vercel", "cloudflare"];
9
+
10
+ /**
11
+ * Deploy a Darkmown site to a platform by wrapping its CLI. The build is
12
+ * target-aware (Cloudflare gets the `dist/_worker.js` for `api/*`), platform
13
+ * config is written if missing, then the platform CLI runs via `npx`.
14
+ *
15
+ * The process spawn is injected (`run`) so this is unit-testable without touching
16
+ * the network; the binary defaults to {@link spawnProcess}.
17
+ * @param {object} opts
18
+ * @param {string} opts.cwd Project directory.
19
+ * @param {string} opts.target One of {@link DEPLOY_TARGETS}.
20
+ * @param {boolean} [opts.prod] Promote to production.
21
+ * @param {(msg: string) => void} opts.log Progress sink.
22
+ * @param {(file: string, args: string[], options: object) => Promise<{ code: number, stdout: string, stderr: string }>} [opts.run]
23
+ * @param {string} [opts.projectName] Cloudflare Pages project name (defaults to package.json name).
24
+ * @returns {Promise<{ url: string | null }>}
25
+ */
26
+ export async function deploy({ cwd, target, prod = false, log, run = spawnProcess, projectName }) {
27
+ if (!DEPLOY_TARGETS.includes(target)) {
28
+ throw new Error(`Unknown deploy target "${target}". Use one of: ${DEPLOY_TARGETS.join(", ")}.`);
29
+ }
30
+
31
+ ensurePlatformConfig(cwd, target);
32
+ log(`Building for ${target}…`);
33
+ buildSite(cwd, { target });
34
+
35
+ const plan = deployCommand({ target, prod, name: projectName || packageName(cwd) });
36
+ log(`$ ${plan.file} ${plan.args.join(" ")}`);
37
+ const result = await run(plan.file, plan.args, { cwd });
38
+ const output = `${result.stdout}\n${result.stderr}`;
39
+
40
+ if (result.code !== 0) {
41
+ // Matches both Vercel ("Not authenticated. Please log in.") and wrangler
42
+ // ("you are not logged in", "Authentication error", "run `wrangler login`",
43
+ // "You need to log in first") auth-failure wording.
44
+ if (
45
+ /not authenticated|not logged in|please log ?in|unauthorized|no auth|log ?in first|authentication (?:error|failed)|wrangler login/i.test(
46
+ output
47
+ )
48
+ ) {
49
+ throw new Error(
50
+ `The ${target} CLI is not signed in. Run \`${plan.loginHint}\` (try \`! ${plan.loginHint}\` in this session), then re-run \`darkmown deploy ${target}\`.`
51
+ );
52
+ }
53
+ throw new Error(`${target} deploy failed (exit ${result.code}).`);
54
+ }
55
+
56
+ const url = plan.parseUrl(output);
57
+ if (url) log(`Deployed → ${url}`);
58
+ else log("Deploy finished (no URL detected in the CLI output).");
59
+ return { url };
60
+ }
61
+
62
+ /**
63
+ * Build the platform CLI invocation: binary, args, login hint, and a URL parser.
64
+ * @param {{ target: string, prod: boolean, name: string }} opts
65
+ * @returns {{ file: string, args: string[], loginHint: string, parseUrl: (out: string) => string | null }}
66
+ */
67
+ function deployCommand({ target, prod, name }) {
68
+ if (target === "vercel") {
69
+ const args = ["--yes", "vercel", "deploy", "--yes"];
70
+ if (prod) args.push("--prod");
71
+ return {
72
+ file: "npx",
73
+ args,
74
+ loginHint: "npx vercel login",
75
+ parseUrl: (out) => firstMatch(out, /https:\/\/[^\s]*\.vercel\.app[^\s]*/)
76
+ };
77
+ }
78
+ // cloudflare — `--branch main` promotes to production only if the project's
79
+ // configured production branch is literally `main` (Cloudflare's default).
80
+ const args = ["--yes", "wrangler", "pages", "deploy", "dist", "--project-name", name];
81
+ if (prod) args.push("--branch", "main");
82
+ return {
83
+ file: "npx",
84
+ args,
85
+ loginHint: "npx wrangler login",
86
+ parseUrl: (out) => firstMatch(out, /https:\/\/[^\s]*\.pages\.dev[^\s]*/)
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Ensure the platform has the config it needs. Vercel reads `vercel.json` BEFORE
92
+ * the build, so write a sane default if absent (never overwrite). Cloudflare needs
93
+ * nothing extra — the build emits `dist/_worker.js` + `dist/_headers`.
94
+ * @param {string} cwd
95
+ * @param {string} target
96
+ * @returns {void}
97
+ */
98
+ function ensurePlatformConfig(cwd, target) {
99
+ if (target !== "vercel") return;
100
+ const file = path.join(cwd, "vercel.json");
101
+ if (fs.existsSync(file)) return;
102
+ const config = {
103
+ $schema: "https://openapi.vercel.sh/vercel.json",
104
+ framework: null,
105
+ buildCommand: "npm run build",
106
+ outputDirectory: "dist",
107
+ cleanUrls: true,
108
+ trailingSlash: false,
109
+ headers: [
110
+ {
111
+ source: "/(.*)",
112
+ headers: [
113
+ ...Object.entries(BASE_SECURITY_HEADERS).map(([key, value]) => ({ key, value })),
114
+ { key: "Content-Security-Policy", value: REACTIVE_CSP }
115
+ ]
116
+ }
117
+ ]
118
+ };
119
+ fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
120
+ }
121
+
122
+ /**
123
+ * Spawn a process, streaming its output to the terminal while capturing it for
124
+ * URL parsing. Resolves (never rejects) with the exit code + captured streams.
125
+ * @param {string} file
126
+ * @param {string[]} args
127
+ * @param {object} options
128
+ * @returns {Promise<{ code: number, stdout: string, stderr: string }>}
129
+ */
130
+ export function spawnProcess(file, args, options) {
131
+ return new Promise((resolve) => {
132
+ let stdout = "";
133
+ let stderr = "";
134
+ const child = spawn(file, args, { ...options, stdio: ["inherit", "pipe", "pipe"] });
135
+ child.stdout.on("data", (chunk) => {
136
+ stdout += chunk;
137
+ process.stdout.write(chunk);
138
+ });
139
+ child.stderr.on("data", (chunk) => {
140
+ stderr += chunk;
141
+ process.stderr.write(chunk);
142
+ });
143
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
144
+ child.on("error", (err) => resolve({ code: 1, stdout, stderr: `${stderr}${err}` }));
145
+ });
146
+ }
147
+
148
+ /**
149
+ * @param {string} text
150
+ * @param {RegExp} re
151
+ * @returns {string | null}
152
+ */
153
+ function firstMatch(text, re) {
154
+ const match = text.match(re);
155
+ // The greedy `[^\s]*` can swallow trailing sentence punctuation (e.g. a period
156
+ // after the URL in "Deployed to https://x.pages.dev."); strip it off.
157
+ return match ? match[0].replace(/[.,;:)\]}'"]+$/, "") : null;
158
+ }
159
+
160
+ /**
161
+ * The project's package.json `name`, or the directory name, as a Cloudflare Pages
162
+ * project slug.
163
+ * @param {string} cwd
164
+ * @returns {string}
165
+ */
166
+ function packageName(cwd) {
167
+ try {
168
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
169
+ if (pkg.name) return String(pkg.name).replace(/^@[^/]+\//, "");
170
+ } catch {
171
+ /* no package.json — fall back to the directory name */
172
+ }
173
+ return path.basename(cwd) || "darkmown-site";
174
+ }
package/src/headers.js CHANGED
@@ -22,6 +22,9 @@
22
22
  * - `:video`/`:audio` allow relative or `http(s)` sources — `media-src 'self' https:`.
23
23
  * - `:embed` rewrites to exactly the YouTube no-cookie and Vimeo player origins,
24
24
  * so `frame-src` pre-authorizes those two hosts (and nothing else).
25
+ * - A native `:form action="/api/…"` POSTs same-origin, so `form-action 'self'`
26
+ * authorizes it (and blocks a form exfiltrating to a third-party origin). Apps
27
+ * posting to a remote backend widen this alongside `connect-src`.
25
28
  *
26
29
  * Static pages (zero framework JS) still emit inline `<style>` and an inline
27
30
  * `<script type="speculationrules">`, so they keep `'unsafe-inline'`, but they
@@ -51,6 +54,7 @@ const COMMON_CSP_DIRECTIVES = [
51
54
  CONNECT_SRC,
52
55
  "object-src 'none'",
53
56
  "base-uri 'self'",
57
+ "form-action 'self'",
54
58
  "frame-ancestors 'self'"
55
59
  ];
56
60
 
package/src/runtime.js CHANGED
@@ -587,7 +587,9 @@ function render() {
587
587
  document.addEventListener("input", (event) => {
588
588
  const input = /** @type {HTMLInputElement | null} */ (/** @type {Element} */ (event.target)?.closest("[data-wd-bind-input]"));
589
589
  if (!input) return;
590
- state[input.getAttribute("data-wd-bind-input") || ""] = input.value;
590
+ // A `:slider` binds a range input: keep state numeric so `:computed`/math see a
591
+ // number, not the DOM's string `.value`. Other inputs stay strings as before.
592
+ state[input.getAttribute("data-wd-bind-input") || ""] = input.type === "range" ? input.valueAsNumber : input.value;
591
593
  savePersisted();
592
594
  render();
593
595
  checkRefetch();