@zvndev/darkmown 1.1.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 (54) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +54 -3
  3. package/docs/cli.md +46 -16
  4. package/docs/spec-alignment.md +9 -3
  5. package/package.json +1 -1
  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 +21 -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/src/api-runner.js +219 -0
  15. package/src/behaviors/carousel.js +153 -0
  16. package/src/behaviors/sortable.js +175 -0
  17. package/src/builder.js +199 -4
  18. package/src/cli.js +79 -54
  19. package/src/compiler/body.js +14 -0
  20. package/src/compiler/context.js +12 -1
  21. package/src/compiler/directives.js +123 -0
  22. package/src/compiler/loops.js +57 -8
  23. package/src/compiler/page.js +9 -3
  24. package/src/deploy.js +174 -0
  25. package/src/headers.js +4 -0
  26. package/src/runtime.js +3 -1
  27. package/src/scaffold.js +79 -147
  28. package/src/templates/blog/README.md +25 -0
  29. package/src/templates/blog/site/_/nav.wd +6 -0
  30. package/src/templates/blog/site/_/posts.json +16 -0
  31. package/src/templates/blog/site/pages/index.skin +51 -0
  32. package/src/templates/blog/site/pages/index.wd +24 -0
  33. package/src/templates/blog/site/pages/posts/hello-darkmown.md +16 -0
  34. package/src/templates/blog/site/pages/posts/markdown-native.md +20 -0
  35. package/src/templates/dashboard/README.md +25 -0
  36. package/src/templates/dashboard/api/metrics.js +15 -0
  37. package/src/templates/dashboard/site/_/nav.wd +6 -0
  38. package/src/templates/dashboard/site/pages/index.skin +74 -0
  39. package/src/templates/dashboard/site/pages/index.wd +75 -0
  40. package/src/templates/landing/README.md +25 -0
  41. package/src/templates/landing/site/_/nav.wd +6 -0
  42. package/src/templates/landing/site/pages/index.skin +140 -0
  43. package/src/templates/landing/site/pages/index.wd +54 -0
  44. package/src/templates/starter/README.md +32 -0
  45. package/src/templates/starter/site/_/nav.wd +7 -0
  46. package/src/templates/starter/site/pages/about.md +10 -0
  47. package/src/templates/starter/site/pages/index.skin +47 -0
  48. package/src/templates/starter/site/pages/index.wd +27 -0
  49. package/src/templates/store/README.md +28 -0
  50. package/src/templates/store/api/checkout.js +18 -0
  51. package/src/templates/store/site/_/nav.wd +6 -0
  52. package/src/templates/store/site/_/products.json +8 -0
  53. package/src/templates/store/site/pages/index.skin +100 -0
  54. package/src/templates/store/site/pages/index.wd +83 -0
package/src/builder.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { discoverApiRoutes } from "./api-runner.js";
4
5
  import { compilePage } from "./compiler.js";
5
6
  import { createPaths } from "./config.js";
6
7
  import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
@@ -24,10 +25,19 @@ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
24
25
 
25
26
  /**
26
27
  * Compile the whole site under `cwd` into `dist`, returning the route manifest.
28
+ *
29
+ * The build is always 100% static, CDN-cacheable HTML. Backend `api/*.js`
30
+ * functions are NOT compiled — they are the host's concern. `target` only
31
+ * decides the small amount of host glue Darkmown emits alongside the static
32
+ * output: the default (`vercel`/anything) leaves `api/` for the platform to pick
33
+ * up natively; `cloudflare` emits a `dist/_worker.js/` module that routes
34
+ * `/api/*` to those same functions and serves everything else as a static asset.
35
+ *
27
36
  * @param {string} [cwd] Project working directory (defaults to `process.cwd()`).
37
+ * @param {{ target?: string }} [options] `target: "cloudflare"` emits the Pages worker.
28
38
  * @returns {{ routes: RouteManifestEntry[], distRoot: string }}
29
39
  */
30
- export function buildSite(cwd = process.cwd()) {
40
+ export function buildSite(cwd = process.cwd(), options = {}) {
31
41
  const paths = createPaths(cwd);
32
42
  fs.rmSync(paths.distRoot, { recursive: true, force: true });
33
43
  fs.mkdirSync(paths.distRoot, { recursive: true });
@@ -55,14 +65,18 @@ export function buildSite(cwd = process.cwd()) {
55
65
  if (route.route === "/404/") notFoundHtml = page.html;
56
66
  emitAssets(page.assets, paths);
57
67
  if (page.assets.runtime) emitRuntime(paths);
68
+ emitBehaviors(page.assets, paths);
69
+ const behaviorSrcs = [...page.assets.behaviors].map((name) => `/__wd/behaviors/${name}.js`);
58
70
  manifest.push({
59
71
  route: route.route,
60
72
  file: path.relative(cwd, route.file).replaceAll(path.sep, "/"),
61
73
  assets: {
62
74
  skins: [...page.assets.skins],
63
- scripts: page.assets.runtime
64
- ? ["/__wd/runtime.js", ...page.assets.scripts]
65
- : [...page.assets.scripts],
75
+ scripts: [
76
+ ...(page.assets.runtime ? ["/__wd/runtime.js"] : []),
77
+ ...behaviorSrcs,
78
+ ...page.assets.scripts
79
+ ],
66
80
  runtime: page.assets.runtime
67
81
  }
68
82
  });
@@ -85,9 +99,173 @@ export function buildSite(cwd = process.cwd()) {
85
99
  // sees every emitted route/framework file already on disk.
86
100
  emitPageAssets(paths);
87
101
 
102
+ // Surface api/ routing footguns on every build (target-independent): two files
103
+ // that resolve to the same /api path mean one is silently unreachable.
104
+ const apiDir = path.join(cwd, "api");
105
+ const apiRoutes = discoverApiRoutes(apiDir);
106
+ warnApiRouteCollisions(apiRoutes, apiDir);
107
+
108
+ if (options.target === "cloudflare") {
109
+ emitCloudflareWorker(paths, apiDir, apiRoutes);
110
+ }
111
+
88
112
  return { routes: manifest, distRoot: paths.distRoot };
89
113
  }
90
114
 
115
+ /**
116
+ * Warn when more than one handler resolves to the same `/api` path, since only
117
+ * one is ever reachable and readdir order silently decides which. Two collisions
118
+ * are caught: an exact duplicate (`api/users.js` + `api/users/index.js`) and two
119
+ * dynamic siblings at the same position (`api/[id].js` + `api/[slug].js`), which
120
+ * is why dynamic segments are normalized to `[*]` before grouping.
121
+ * @param {import("./api-runner.js").ApiRoute[]} routes
122
+ * @param {string} apiDir
123
+ * @returns {void}
124
+ */
125
+ function warnApiRouteCollisions(routes, apiDir) {
126
+ /** @type {Map<string, string[]>} */
127
+ const byPath = new Map();
128
+ for (const route of routes) {
129
+ const key = route.segments.map((s) => (s.startsWith("[") ? "[*]" : s)).join("/");
130
+ const list = byPath.get(key) || [];
131
+ list.push(path.relative(apiDir, route.file).replaceAll(path.sep, "/"));
132
+ byPath.set(key, list);
133
+ }
134
+ for (const [key, files] of byPath) {
135
+ if (files.length > 1) {
136
+ console.warn(
137
+ `hint: ${files.length} api handlers resolve to /api/${key} — only one is reachable. ` +
138
+ `Rename or remove: ${files.join(", ")}.`
139
+ );
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Emit `dist/_worker.js/` for a Cloudflare Pages deploy (advanced mode). The
146
+ * project's `api/*.js` functions are copied in, and a generated `index.js` routes
147
+ * `/api/*` to them (Web-standard `(request, { params, env }) => Response`) and
148
+ * falls through to `env.ASSETS.fetch(request)` for static files — so `_headers`
149
+ * still applies to assets. No-op when the project has no `api/` functions.
150
+ *
151
+ * Handlers must be dependency-free (or pre-bundled): advanced mode does not run a
152
+ * bundler, so bare npm imports won't resolve. We scan copied handlers and warn on
153
+ * any bare specifier. Same source still runs on Vercel (which does bundle) and in
154
+ * `darkmown dev` — the shape is identical.
155
+ * @param {Paths} paths
156
+ * @param {string} apiDir Absolute path to the project's `api/` directory.
157
+ * @param {import("./api-runner.js").ApiRoute[]} routes Discovered api routes.
158
+ * @returns {void}
159
+ */
160
+ function emitCloudflareWorker(paths, apiDir, routes) {
161
+ if (routes.length === 0) return;
162
+
163
+ const workerDir = path.join(paths.distRoot, "_worker.js");
164
+ fs.mkdirSync(workerDir, { recursive: true });
165
+
166
+ /** @type {{ ident: string, spec: string, segments: string[] }[]} */
167
+ const entries = [];
168
+ routes.forEach((route, i) => {
169
+ const rel = path.relative(apiDir, route.file).replaceAll(path.sep, "/");
170
+ const out = path.join(workerDir, "api", rel);
171
+ fs.mkdirSync(path.dirname(out), { recursive: true });
172
+ const source = fs.readFileSync(route.file, "utf8");
173
+ warnBareImports(source, `api/${rel}`);
174
+ fs.writeFileSync(out, source);
175
+ entries.push({ ident: `h${i}`, spec: `./api/${rel}`, segments: route.segments });
176
+ });
177
+
178
+ const imports = entries
179
+ .map((e) => `import ${e.ident} from ${JSON.stringify(e.spec)};`)
180
+ .join("\n");
181
+ const table = entries
182
+ .map((e) => ` { segments: ${JSON.stringify(e.segments)}, handler: ${e.ident} }`)
183
+ .join(",\n");
184
+
185
+ fs.writeFileSync(path.join(workerDir, "index.js"), cloudflareWorkerSource(imports, table));
186
+ }
187
+
188
+ /**
189
+ * Warn when a Cloudflare-bound handler imports a bare npm package. Advanced mode
190
+ * runs no bundler, so only relative (`./`, `../`) and `node:` specifiers resolve
191
+ * at the edge; a bare `import x from "some-pkg"` would fail at runtime with no
192
+ * build-time signal. Advisory only — relative/`node:`/data imports pass clean.
193
+ * @param {string} source Handler source.
194
+ * @param {string} label Human-readable handler path for the message.
195
+ * @returns {void}
196
+ */
197
+ function warnBareImports(source, label) {
198
+ /** @type {Set<string>} */
199
+ const bare = new Set();
200
+ const re =
201
+ /(?:^|[^.\w])(?:import|export)\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]|(?:^|[^.\w])import\s*\(?\s*['"]([^'"]+)['"]/g;
202
+ for (let m = re.exec(source); m; m = re.exec(source)) {
203
+ const spec = m[1] || m[2];
204
+ if (spec && !/^[./]/.test(spec) && !spec.startsWith("node:")) bare.add(spec);
205
+ }
206
+ if (bare.size > 0) {
207
+ console.warn(
208
+ `hint: ${label} imports ${[...bare].map((s) => `"${s}"`).join(", ")} — Cloudflare ` +
209
+ "advanced mode runs no bundler, so bare npm imports won't resolve at the edge. " +
210
+ "Inline the dependency or pre-bundle the handler."
211
+ );
212
+ }
213
+ }
214
+
215
+ /**
216
+ * The generated Cloudflare Pages `_worker.js/index.js` source. Mirrors the
217
+ * dev runner's matching (static beats dynamic; `[param]` capture; `index`
218
+ * collapses to its directory) so routing is identical across dev and prod.
219
+ * @param {string} imports
220
+ * @param {string} table
221
+ * @returns {string}
222
+ */
223
+ function cloudflareWorkerSource(imports, table) {
224
+ return `// Generated by Darkmown for Cloudflare Pages (advanced mode). Routes /api/* to
225
+ // the project's api functions; every other request is a static asset.
226
+ ${imports}
227
+
228
+ const routes = [
229
+ ${table}
230
+ ];
231
+
232
+ function match(pathname) {
233
+ if (pathname !== "/api" && !pathname.startsWith("/api/")) return null;
234
+ const parts = pathname.split("/").filter(Boolean).slice(1);
235
+ for (const route of routes) {
236
+ if (route.segments.length !== parts.length) continue;
237
+ const params = {};
238
+ let ok = true;
239
+ for (let i = 0; i < parts.length; i++) {
240
+ const seg = route.segments[i];
241
+ if (seg[0] === "[" && seg[seg.length - 1] === "]") {
242
+ params[seg.slice(1, -1)] = decodeURIComponent(parts[i]);
243
+ } else if (seg !== parts[i]) {
244
+ ok = false;
245
+ break;
246
+ }
247
+ }
248
+ if (ok) return { handler: route.handler, params };
249
+ }
250
+ return null;
251
+ }
252
+
253
+ export default {
254
+ async fetch(request, env, ctx) {
255
+ const matched = match(new URL(request.url).pathname);
256
+ if (!matched) return env.ASSETS.fetch(request);
257
+ try {
258
+ const response = await matched.handler(request, { params: matched.params, env });
259
+ if (response instanceof Response) return response;
260
+ return Response.json({ ok: false, error: "Handler did not return a Response" }, { status: 500 });
261
+ } catch (err) {
262
+ return Response.json({ ok: false, error: String((err && err.message) || err) }, { status: 500 });
263
+ }
264
+ }
265
+ };
266
+ `;
267
+ }
268
+
91
269
  /**
92
270
  * Strip developer comments from the runtime before it ships. The source keeps
93
271
  * full JSDoc type annotations (for `checkJs` + `.d.ts`) and explanatory comments
@@ -192,6 +370,23 @@ function emitRuntime(paths) {
192
370
  fs.writeFileSync(out, stripRuntimeComments(source));
193
371
  }
194
372
 
373
+ /**
374
+ * Emit the pay-for-what-you-use behavior modules a page asked for, comment-
375
+ * stripped like the runtime. Each is a standalone `/__wd/behaviors/<name>.js`
376
+ * module, NOT part of `runtime.js`, so the core runtime budget is untouched.
377
+ * @param {Assets} assets
378
+ * @param {Paths} paths
379
+ * @returns {void}
380
+ */
381
+ function emitBehaviors(assets, paths) {
382
+ for (const name of assets.behaviors) {
383
+ const source = fs.readFileSync(path.join(moduleDir, "behaviors", `${name}.js`), "utf8");
384
+ const out = path.join(paths.distRoot, "__wd/behaviors", `${name}.js`);
385
+ fs.mkdirSync(path.dirname(out), { recursive: true });
386
+ fs.writeFileSync(out, stripRuntimeComments(source));
387
+ }
388
+ }
389
+
195
390
  /**
196
391
  * @param {Assets} assets
197
392
  * @param {Paths} paths
package/src/cli.js CHANGED
@@ -4,9 +4,11 @@ import fs from "node:fs";
4
4
  import http from "node:http";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { handleApiRequest } from "./api-runner.js";
7
8
  import { buildSite } from "./builder.js";
9
+ import { DEPLOY_TARGETS, deploy } from "./deploy.js";
8
10
  import { devClientPath, devClientScript, devEventsPath, injectDevClient } from "./dev.js";
9
- import { initProject } from "./scaffold.js";
11
+ import { availableTemplates, initProject } from "./scaffold.js";
10
12
  import { contentType, resolvePublicFile, serve } from "./statics.js";
11
13
 
12
14
  const cliPath = fileURLToPath(import.meta.url);
@@ -27,6 +29,7 @@ const cliPath = fileURLToPath(import.meta.url);
27
29
  * @property {(message?: unknown, ...rest: unknown[]) => void} [log] stdout sink.
28
30
  * @property {(message?: unknown, ...rest: unknown[]) => void} [warn] stderr (warn) sink.
29
31
  * @property {(message?: unknown, ...rest: unknown[]) => void} [error] stderr (error) sink.
32
+ * @property {(file: string, args: string[], options: object) => Promise<{ code: number, stdout: string, stderr: string }>} [spawn] Process runner for `deploy` (defaults to a real spawn); injectable for tests.
30
33
  *
31
34
  * @param {string[]} argv The argv tail (i.e. `process.argv.slice(2)`).
32
35
  * @param {RunEnv} [env]
@@ -51,16 +54,20 @@ export async function run(argv, env = {}) {
51
54
  return { command: "version" };
52
55
  }
53
56
  if (command === "init") {
54
- const target = argv[1] || ".";
55
- const result = initProject(path.resolve(cwd, target));
57
+ const target = argv.slice(1).find((arg) => !arg.startsWith("-")) || ".";
58
+ const template = flagValue(argv, "--template");
59
+ const result = initProject(path.resolve(cwd, target), { template });
56
60
  const relativeRoot = path.relative(cwd, result.root) || ".";
57
- log(`Created Darkmown project at ${relativeRoot}`);
61
+ log(`Created Darkmown project (${result.template}) at ${relativeRoot}`);
58
62
  log(`Next: ${nextStep(relativeRoot)}`);
59
63
  return { command: "init" };
60
64
  }
61
65
  if (command === "build") {
62
- const result = buildSite(cwd);
66
+ const target = flagValue(argv, "--target");
67
+ const result = buildSite(cwd, { target });
63
68
  log(`Built ${result.routes.length} routes into ${path.relative(cwd, result.distRoot)}`);
69
+ if (target === "cloudflare")
70
+ log("Emitted dist/_worker.js for Cloudflare Pages api/* functions");
64
71
  return { command: "build" };
65
72
  }
66
73
  if (command === "dev") {
@@ -69,6 +76,15 @@ export async function run(argv, env = {}) {
69
76
  if (command === "serve") {
70
77
  return startPreviewServer({ cwd, log, error });
71
78
  }
79
+ if (command === "deploy") {
80
+ const target = argv.slice(1).find((arg) => !arg.startsWith("-"));
81
+ if (!target) {
82
+ error(`Usage: darkmown deploy <${DEPLOY_TARGETS.join("|")}> [--prod]`);
83
+ throw new CliError("Missing deploy target", { silent: true });
84
+ }
85
+ await deploy({ cwd, target, prod: argv.includes("--prod"), log, run: env.spawn });
86
+ return { command: "deploy" };
87
+ }
72
88
  // No command matched: report it, show help, and fail like the binary always
73
89
  // did (exit 1). `silent` keeps `main` from re-printing a `✗` line on top.
74
90
  error(`Unknown command: ${command}`);
@@ -157,52 +173,43 @@ function startDevServer({ cwd, log, warn, error }) {
157
173
  }
158
174
  }
159
175
 
176
+ const apiDir = path.join(cwd, "api");
160
177
  const server = http.createServer((req, res) => {
161
- try {
162
- const url = req.url || "/";
163
- if (url.split("?")[0] === devEventsPath) {
164
- res.writeHead(200, {
165
- "content-type": "text/event-stream",
166
- "cache-control": "no-cache",
167
- connection: "keep-alive"
168
- });
169
- res.write("\n");
170
- clients.add(res);
171
- // Replay a broken build to clients that connect while it's still failing,
172
- // so the overlay shows even after a startup or pre-connect compile error.
173
- if (lastBuildError) {
174
- res.write(
175
- `event: builderror\ndata: ${JSON.stringify({ message: lastBuildError.slice(0, 4000) })}\n\n`
176
- );
177
- }
178
- req.on("close", () => clients.delete(res));
179
- return;
180
- }
181
- if (url.split("?")[0] === devClientPath) {
182
- res.writeHead(200, { "content-type": "text/javascript" });
183
- res.end(devClientScript());
184
- return;
185
- }
186
- if (url.split("?")[0] === "/__wd/echo" && req.method === "POST") {
187
- let body = "";
188
- req.on("data", (chunk) => (body += chunk));
189
- req.on("end", () => {
190
- res.writeHead(200, { "content-type": "application/json" });
191
- res.end(
192
- JSON.stringify({
193
- ok: true,
194
- received: Object.fromEntries(new URLSearchParams(body)),
195
- at: new Date().toISOString()
196
- })
197
- );
198
- });
199
- return;
178
+ const url = req.url || "/";
179
+ if (url.split("?")[0] === devEventsPath) {
180
+ res.writeHead(200, {
181
+ "content-type": "text/event-stream",
182
+ "cache-control": "no-cache",
183
+ connection: "keep-alive"
184
+ });
185
+ res.write("\n");
186
+ clients.add(res);
187
+ // Replay a broken build to clients that connect while it's still failing,
188
+ // so the overlay shows even after a startup or pre-connect compile error.
189
+ if (lastBuildError) {
190
+ res.write(
191
+ `event: builderror\ndata: ${JSON.stringify({ message: lastBuildError.slice(0, 4000) })}\n\n`
192
+ );
200
193
  }
201
- serveDev(distRoot, url, res);
202
- } catch (err) {
203
- res.writeHead(500, { "content-type": "text/plain" });
204
- res.end((err instanceof Error && err.stack) || String(err));
194
+ req.on("close", () => clients.delete(res));
195
+ return;
205
196
  }
197
+ if (url.split("?")[0] === devClientPath) {
198
+ res.writeHead(200, { "content-type": "text/javascript" });
199
+ res.end(devClientScript());
200
+ return;
201
+ }
202
+ // Serverless parity: run a project `api/*.js` function if the path matches,
203
+ // exactly as Vercel/Cloudflare would in production; otherwise serve static. A
204
+ // throw on either path (e.g. a stat/read error) becomes a clean 500.
205
+ handleApiRequest({ apiDir, req, res })
206
+ .then((handled) => {
207
+ if (!handled) serveDev(distRoot, url, res);
208
+ })
209
+ .catch((err) => {
210
+ res.writeHead(500, { "content-type": "text/plain" });
211
+ res.end((err instanceof Error && err.stack) || String(err));
212
+ });
206
213
  });
207
214
 
208
215
  return new Promise((resolve) => {
@@ -293,16 +300,20 @@ export function helpText() {
293
300
  return `Darkmown
294
301
 
295
302
  Usage:
296
- darkmown init [dir] Scaffold a new Darkmown project
297
- darkmown dev Start the live compiler dev server
298
- darkmown build Compile site/pages into dist
299
- darkmown serve Preview the built dist locally
300
- darkmown version Print the installed Darkmown version
301
- darkmown help Show this help
303
+ darkmown init [dir] [--template <name>] Scaffold a new project from a template
304
+ darkmown dev Start the live compiler dev server
305
+ darkmown build [--target cloudflare] Compile site/pages into dist
306
+ darkmown deploy <${DEPLOY_TARGETS.join("|")}> [--prod] Build + deploy to a platform
307
+ darkmown serve Preview the built dist locally
308
+ darkmown version Print the installed Darkmown version
309
+ darkmown help Show this help
310
+
311
+ Templates (init --template <name>): ${availableTemplates().join(", ")}
302
312
 
303
313
  Authoring:
304
314
  site/pages File-based routes: .md stays plain, .wd adds directives
305
315
  site/_ Include shelf for @include /name.wd
316
+ api/ Serverless functions: export default (request) => Response
306
317
  @loop x into item Loop over JSON files, in-scope values, or :state lists
307
318
  *.skin Colocated indentation-based CSS
308
319
  *.js Colocated page behavior
@@ -318,6 +329,20 @@ export function nextStep(relativeRoot) {
318
329
  return `cd ${relativeRoot} && npm install && npm run dev`;
319
330
  }
320
331
 
332
+ /**
333
+ * Read a `--flag value` or `--flag=value` option out of an argv tail.
334
+ * @param {string[]} argv The argv tail.
335
+ * @param {string} flag The flag name, e.g. `"--target"`.
336
+ * @returns {string | undefined} The value, or undefined when the flag is absent.
337
+ */
338
+ export function flagValue(argv, flag) {
339
+ const inline = argv.find((arg) => arg.startsWith(`${flag}=`));
340
+ if (inline) return inline.slice(flag.length + 1);
341
+ const index = argv.indexOf(flag);
342
+ if (index !== -1 && index + 1 < argv.length) return argv[index + 1];
343
+ return undefined;
344
+ }
345
+
321
346
  /**
322
347
  * Binary entry point: read argv, dispatch, and map failures to a clean
323
348
  * `✗`-prefixed line + exit code 1 — never a Node stack trace. The thrown Error
@@ -11,6 +11,7 @@ import { at } from "./context.js";
11
11
  import {
12
12
  handleBind,
13
13
  handleButton,
14
+ handleCarousel,
14
15
  handleChoiceGroup,
15
16
  handleComputed,
16
17
  handleContainer,
@@ -24,6 +25,7 @@ import {
24
25
  handleInput,
25
26
  handleMedia,
26
27
  handleSelect,
28
+ handleSlider,
27
29
  handleState,
28
30
  handleStore,
29
31
  handleSubmit,
@@ -155,6 +157,13 @@ export function compileBody(lines, ctx) {
155
157
  i = block.end;
156
158
  continue;
157
159
  }
160
+ if (/^:carousel(?:\s|$)/.test(line)) {
161
+ flush();
162
+ const block = scanBlock(lines, i, /^:carousel(?:\s|$)/, ":endcarousel", ctx.file);
163
+ out.push(handleCarousel(line, block.body, ctx, i));
164
+ i = block.end;
165
+ continue;
166
+ }
158
167
  if (/^:input\s/.test(line)) {
159
168
  flush();
160
169
  out.push(handleInput(line, ctx, i));
@@ -197,6 +206,11 @@ export function compileBody(lines, ctx) {
197
206
  out.push(handleBind(line, ctx, i));
198
207
  continue;
199
208
  }
209
+ if (/^:slider\s/.test(line)) {
210
+ flush();
211
+ out.push(handleSlider(line, ctx, i));
212
+ continue;
213
+ }
200
214
  if (/^:submit\s/.test(line)) {
201
215
  flush();
202
216
  out.push(handleSubmit(line, ctx, i));
@@ -29,6 +29,9 @@
29
29
  * @property {Set<string>} scripts Public hrefs of colocated page scripts.
30
30
  * @property {Map<string, string>} files Source path → public href for emitted assets.
31
31
  * @property {boolean} runtime Whether the reactive runtime is required.
32
+ * @property {Set<string>} behaviors Names of framework behavior modules this page
33
+ * needs (`slider` is compile-time only and never appears here; `sortable`,
34
+ * `carousel` each emit a pay-for-what-you-use `/__wd/behaviors/<name>.js`).
32
35
  */
33
36
 
34
37
  /**
@@ -103,6 +106,8 @@
103
106
  * @property {NumArg | null} limit
104
107
  * @property {string[] | null} empty Empty-branch body lines, if any.
105
108
  * @property {boolean} clauseRefsState Whether offset/limit reference state.
109
+ * @property {boolean} sortable Drag-to-reorder the underlying :state/:store list
110
+ * (the `sortable` clause). Only valid on a plain reactive state-key loop.
106
111
  */
107
112
 
108
113
  /**
@@ -130,7 +135,13 @@ export const LOOP_META = {
130
135
  /** @returns {Compilation} */
131
136
  export function createCompilation() {
132
137
  return {
133
- assets: { skins: new Set(), scripts: new Set(), files: new Map(), runtime: false },
138
+ assets: {
139
+ skins: new Set(),
140
+ scripts: new Set(),
141
+ files: new Map(),
142
+ runtime: false,
143
+ behaviors: new Set()
144
+ },
134
145
  state: new Map(),
135
146
  stores: new Set(),
136
147
  warnings: [],
@@ -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