@zvndev/darkmown 1.5.0 → 2.0.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 +17 -3
- package/CHANGELOG.md +28 -0
- package/README.md +44 -16
- package/SECURITY.md +21 -18
- package/docs/cli.md +5 -1
- package/docs/spec-alignment.md +19 -6
- package/package.json +1 -1
- package/site/_/base.skin +8 -0
- package/site/_/base.wd +4 -0
- package/site/_/feature-card.wd +4 -0
- package/site/_/footer.wd +4 -0
- package/site/_/nav.wd +4 -0
- package/site/_/scoped-promo.wd +4 -0
- package/site/_/scoped-stat.wd +4 -0
- package/site/pages/404.wd +1 -0
- package/site/pages/app.wd +1 -0
- package/site/pages/blog/collections-and-pagination.skin +127 -0
- package/site/pages/blog/drafts-that-never-leak.skin +127 -0
- package/site/pages/blog/index.wd +1 -0
- package/site/pages/blog/shipping-feeds.skin +127 -0
- package/site/pages/blog/typed-frontmatter.skin +127 -0
- package/site/pages/blog/unfinished-thoughts.skin +127 -0
- package/site/pages/blog/zero-js-by-default.skin +127 -0
- package/site/pages/carousel/index.wd +1 -0
- package/site/pages/compass/index.wd +1 -0
- package/site/pages/data.wd +3 -0
- package/site/pages/docs/-relative-note.wd +4 -0
- package/site/pages/docs/index.skin +27 -0
- package/site/pages/docs/index.wd +70 -13
- package/site/pages/folio/-style.wd +4 -0
- package/site/pages/folio/cart.wd +1 -0
- package/site/pages/folio/index.wd +1 -0
- package/site/pages/forge/index.wd +1 -0
- package/site/pages/highlight/index.wd +1 -0
- package/site/pages/index.wd +1 -0
- package/site/pages/interactions/index.wd +1 -0
- package/site/pages/ledger/index.wd +1 -0
- package/site/pages/loops.wd +1 -0
- package/site/pages/media/index.wd +1 -0
- package/site/pages/pulse/index.wd +1 -0
- package/site/pages/reactive.wd +1 -0
- package/site/pages/scoped/index.wd +1 -0
- package/site/pages/showcase/index.wd +1 -0
- package/site/pages/store.wd +1 -0
- package/site/pages/theme.wd +1 -0
- package/src/builder.js +444 -65
- package/src/cli.js +96 -18
- package/src/compiler/actions.js +303 -0
- package/src/compiler/body.js +36 -24
- package/src/compiler/context.js +56 -6
- package/src/compiler/directives.js +31 -1470
- package/src/compiler/fetch.js +159 -0
- package/src/compiler/format.js +17 -4
- package/src/compiler/forms.js +456 -0
- package/src/compiler/frontmatter.js +10 -5
- package/src/compiler/includes.js +66 -1
- package/src/compiler/index.js +15 -3
- package/src/compiler/loops.js +53 -21
- package/src/compiler/markdown.js +65 -15
- package/src/compiler/media.js +133 -0
- package/src/compiler/page.js +59 -75
- package/src/compiler/state.js +168 -0
- package/src/compiler/structure.js +377 -0
- package/src/dev.js +76 -0
- package/src/headers.js +56 -17
- package/src/router.js +12 -1
- package/src/runtime.js +5 -3
- package/src/statics.js +63 -12
- package/src/templates/blog/README.md +13 -4
- package/src/templates/blog/site/_/nav.wd +4 -0
- package/src/templates/blog/site/pages/index.skin +3 -0
- package/src/templates/blog/site/pages/index.wd +10 -4
- package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
- package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
- package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
- package/src/templates/dashboard/site/_/nav.wd +4 -0
- package/src/templates/dashboard/site/pages/index.wd +1 -0
- package/src/templates/landing/site/_/nav.wd +4 -0
- package/src/templates/landing/site/pages/index.wd +1 -0
- package/src/templates/starter/site/_/nav.wd +4 -0
- package/src/templates/starter/site/pages/about.md +18 -1
- package/src/templates/starter/site/pages/about.skin +69 -0
- package/src/templates/starter/site/pages/index.wd +1 -0
- package/src/templates/store/site/_/nav.wd +4 -0
- package/src/templates/store/site/pages/index.wd +1 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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,
|
|
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
|
-
}
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
+
}
|