@zvndev/darkmown 0.12.0 → 0.13.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 +14 -7
- package/CHANGELOG.md +22 -0
- package/README.md +90 -6
- package/SECURITY.md +39 -0
- package/docs/spec-alignment.md +13 -2
- package/package.json +3 -2
- package/site/_/empty.json +1 -0
- package/site/_/og.png +0 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/data.wd +41 -0
- package/site/pages/docs/index.wd +57 -7
- package/site/pages/index.wd +3 -2
- package/site/pages/reactive.wd +20 -7
- package/src/builder.js +32 -0
- package/src/cli.js +31 -8
- package/src/compiler.js +228 -28
- package/src/runtime.js +113 -5
- package/src/statics.js +1 -0
package/src/builder.js
CHANGED
|
@@ -71,6 +71,11 @@ export function buildSite(cwd = process.cwd()) {
|
|
|
71
71
|
fs.writeFileSync(path.join(paths.distRoot, "404.html"), notFoundHtml ?? defaultNotFoundHtml());
|
|
72
72
|
|
|
73
73
|
fs.writeFileSync(path.join(paths.distRoot, "routes.json"), JSON.stringify(manifest, null, 2));
|
|
74
|
+
|
|
75
|
+
// Page-colocated static assets (images, SVG, fonts, …) copy last, so the guard
|
|
76
|
+
// sees every emitted route/framework file already on disk.
|
|
77
|
+
emitPageAssets(paths);
|
|
78
|
+
|
|
74
79
|
return { routes: manifest, distRoot: paths.distRoot };
|
|
75
80
|
}
|
|
76
81
|
|
|
@@ -168,6 +173,33 @@ function emitShelfAssets(paths) {
|
|
|
168
173
|
}
|
|
169
174
|
}
|
|
170
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Copy page-colocated static assets (images, SVG, fonts, …) from `site/pages`
|
|
178
|
+
* into `dist`, preserving their relative path: `site/pages/logo.svg` →
|
|
179
|
+
* `dist/logo.svg`; `site/pages/blog/cover.png` → `dist/blog/cover.png`. Routes
|
|
180
|
+
* (`.md`/`.wd`) and the compiler's colocated `.skin`/`.js` files are skipped —
|
|
181
|
+
* those have their own emit paths. A backstop guard skips any asset whose output
|
|
182
|
+
* would clobber an already-emitted route/framework file (in practice their
|
|
183
|
+
* extensions differ, so this only fires on a genuine name clash).
|
|
184
|
+
* @param {Paths} paths
|
|
185
|
+
* @returns {void}
|
|
186
|
+
*/
|
|
187
|
+
function emitPageAssets(paths) {
|
|
188
|
+
if (!fs.existsSync(paths.routesRoot)) return;
|
|
189
|
+
for (const file of walk(paths.routesRoot)) {
|
|
190
|
+
const ext = path.extname(file).toLowerCase();
|
|
191
|
+
if ([".md", ".wd", ".skin", ".js"].includes(ext)) continue;
|
|
192
|
+
const rel = path.relative(paths.routesRoot, file);
|
|
193
|
+
const out = path.join(paths.distRoot, rel);
|
|
194
|
+
if (fs.existsSync(out)) {
|
|
195
|
+
console.warn(`hint: page asset "${rel}" skipped — a built route already emits /${rel.replaceAll(path.sep, "/")}`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
199
|
+
fs.copyFileSync(file, out);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
171
203
|
/**
|
|
172
204
|
* @param {string} dir
|
|
173
205
|
* @returns {string[]}
|
package/src/cli.js
CHANGED
|
@@ -10,7 +10,9 @@ import { initProject } from "./scaffold.js";
|
|
|
10
10
|
import { contentType, resolvePublicFile, serve } from "./statics.js";
|
|
11
11
|
|
|
12
12
|
const cliPath = fileURLToPath(import.meta.url);
|
|
13
|
-
|
|
13
|
+
// Bare `darkmown` prints help (discovery) rather than silently building — a new
|
|
14
|
+
// user typing the command alone expects to see what it can do, not a no-op build.
|
|
15
|
+
const command = process.argv[2] || "help";
|
|
14
16
|
|
|
15
17
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
16
18
|
printHelp();
|
|
@@ -18,14 +20,24 @@ if (command === "help" || command === "--help" || command === "-h") {
|
|
|
18
20
|
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
19
21
|
console.log(pkg.version);
|
|
20
22
|
} else if (command === "init") {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
try {
|
|
24
|
+
const target = process.argv[3] || ".";
|
|
25
|
+
const result = initProject(path.resolve(process.cwd(), target));
|
|
26
|
+
const relativeRoot = path.relative(process.cwd(), result.root) || ".";
|
|
27
|
+
console.log(`Created Darkmown project at ${relativeRoot}`);
|
|
28
|
+
console.log(`Next: ${nextStep(relativeRoot)}`);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
failClean(error);
|
|
31
|
+
}
|
|
26
32
|
} else if (command === "build") {
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
try {
|
|
34
|
+
const result = buildSite();
|
|
35
|
+
console.log(`Built ${result.routes.length} routes into ${path.relative(process.cwd(), result.distRoot)}`);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
// A compile error has a clear, file-pathed message — print just that, not a
|
|
38
|
+
// Node stack trace, so a typo reads as "fix your page", not "the tool crashed".
|
|
39
|
+
failClean(error);
|
|
40
|
+
}
|
|
29
41
|
} else if (command === "dev") {
|
|
30
42
|
const port = Number(process.env.PORT || 5173);
|
|
31
43
|
const host = process.env.HOST || "127.0.0.1";
|
|
@@ -170,6 +182,17 @@ function broadcast(clients, message) {
|
|
|
170
182
|
for (const client of clients) client.write(message);
|
|
171
183
|
}
|
|
172
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Print a compile/CLI error as a single clean line and exit non-zero — no Node
|
|
187
|
+
* stack trace. The thrown Error already carries the file path + corrective hint.
|
|
188
|
+
* @param {unknown} error
|
|
189
|
+
* @returns {never}
|
|
190
|
+
*/
|
|
191
|
+
function failClean(error) {
|
|
192
|
+
console.error(`✗ ${error instanceof Error ? error.message : String(error)}`);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
|
|
173
196
|
function printHelp() {
|
|
174
197
|
console.log(`Darkmown
|
|
175
198
|
|
package/src/compiler.js
CHANGED
|
@@ -110,13 +110,24 @@ export function compilePage(file, context) {
|
|
|
110
110
|
const compiled = compileDocument(file, context);
|
|
111
111
|
const title = compiled.meta.title || "Darkmown";
|
|
112
112
|
const description = compiled.meta.description || "";
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
: "";
|
|
113
|
+
// Optional `image:` frontmatter sets the social-share preview (absolute URL).
|
|
114
|
+
const image = typeof compiled.meta.image === "string" ? compiled.meta.image : "";
|
|
115
|
+
/** @type {string[]} */
|
|
116
|
+
const social = [];
|
|
117
|
+
if (description) social.push(`<meta name="description" content="${escapeHtml(description)}">`);
|
|
118
|
+
if (description || image) {
|
|
119
|
+
social.push(`<meta property="og:title" content="${escapeHtml(title)}">`);
|
|
120
|
+
if (description) social.push(`<meta property="og:description" content="${escapeHtml(description)}">`);
|
|
121
|
+
social.push(`<meta property="og:type" content="website">`);
|
|
122
|
+
}
|
|
123
|
+
if (image) {
|
|
124
|
+
social.push(`<meta property="og:image" content="${escapeHtml(image)}">`);
|
|
125
|
+
social.push(`<meta name="twitter:image" content="${escapeHtml(image)}">`);
|
|
126
|
+
}
|
|
127
|
+
if (description || image) {
|
|
128
|
+
social.push(`<meta name="twitter:card" content="${image ? "summary_large_image" : "summary"}">`);
|
|
129
|
+
}
|
|
130
|
+
const descriptionTag = social.length ? `\n ${social.join("\n ")}` : "";
|
|
120
131
|
const favicon = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Crect%20width='32'%20height='32'%20rx='6'%20fill='%2318221d'/%3E%3Ctext%20x='16'%20y='23'%20text-anchor='middle'%20font-family='Georgia,serif'%20font-size='19'%20font-weight='bold'%20fill='%23f7f3ea'%3ED%3C/text%3E%3C/svg%3E";
|
|
121
132
|
const cssLinks = [...compiled.assets.skins].map((href) => `<link rel="stylesheet" href="${href}">`).join("\n");
|
|
122
133
|
const scriptSrcs = compiled.assets.runtime ? ["/__wd/runtime.js", ...compiled.assets.scripts] : [...compiled.assets.scripts];
|
|
@@ -366,6 +377,11 @@ function compileBody(lines, ctx) {
|
|
|
366
377
|
out.push(handleComputed(line, ctx));
|
|
367
378
|
continue;
|
|
368
379
|
}
|
|
380
|
+
if (/^:effect\s/.test(line)) {
|
|
381
|
+
flush();
|
|
382
|
+
out.push(handleEffect(line, ctx));
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
369
385
|
if (/^:form\s/.test(line)) {
|
|
370
386
|
flush();
|
|
371
387
|
const block = scanBlock(lines, i, /^:form\s/, ":endform", ctx.file);
|
|
@@ -531,7 +547,13 @@ function scanContainer(lines, start, file) {
|
|
|
531
547
|
}
|
|
532
548
|
|
|
533
549
|
/**
|
|
534
|
-
* Split an `:if … :else … :endif`
|
|
550
|
+
* Split an `:if … :else if … :else … :endif` chain into the first condition's
|
|
551
|
+
* truthy body and a falsy body. A bare `:else` works as before. The first
|
|
552
|
+
* `:else if B` is **desugared** into a nested `:if` that lives in the falsy
|
|
553
|
+
* branch: the falsy body becomes `[":if B", …rest…, ":endif"]`, which re-enters
|
|
554
|
+
* `compileBody`/`handleIf` recursively. A whole `:if/:else if/:else` chain
|
|
555
|
+
* therefore compiles to nested `data-wd-if` regions the runtime already drives —
|
|
556
|
+
* identical behavior for static, reactive, and loop-row conditionals.
|
|
535
557
|
* @param {string[]} lines
|
|
536
558
|
* @param {number} start Index of the `:if` line.
|
|
537
559
|
* @param {string} file Source path for errors.
|
|
@@ -543,6 +565,11 @@ function scanConditional(lines, start, file) {
|
|
|
543
565
|
/** @type {string[]} */
|
|
544
566
|
const falsy = [];
|
|
545
567
|
let current = truthy;
|
|
568
|
+
// "truthy": collecting the :if body. "else": a bare :else closed the chain —
|
|
569
|
+
// no further branches allowed. "elseif": the first :else if was desugared into
|
|
570
|
+
// a nested :if inside `falsy`; subsequent depth-0 :else if/:else lines belong to
|
|
571
|
+
// that nested chain and are emitted verbatim for the recursive compile to split.
|
|
572
|
+
let mode = "truthy";
|
|
546
573
|
let depth = 0;
|
|
547
574
|
let fence = null;
|
|
548
575
|
for (let i = start + 1; i < lines.length; i++) {
|
|
@@ -560,12 +587,28 @@ function scanConditional(lines, start, file) {
|
|
|
560
587
|
}
|
|
561
588
|
if (/^:if\s/.test(line)) depth++;
|
|
562
589
|
if (line.trim() === ":endif") {
|
|
563
|
-
if (depth === 0)
|
|
590
|
+
if (depth === 0) {
|
|
591
|
+
if (mode === "elseif") falsy.push(":endif"); // close the synthesized nested :if
|
|
592
|
+
return { truthy, falsy, end: i };
|
|
593
|
+
}
|
|
564
594
|
depth--;
|
|
565
595
|
}
|
|
566
|
-
if (
|
|
567
|
-
|
|
568
|
-
|
|
596
|
+
if (depth === 0 && mode !== "elseif") {
|
|
597
|
+
const t = line.trim();
|
|
598
|
+
const elseIf = t.match(/^:else if\s+(.+?)\s*$/);
|
|
599
|
+
if (elseIf) {
|
|
600
|
+
if (mode === "else") throw new Error(`":else if" after ":else" in ${file}. ":else" must be the last branch — order the "else if" conditions before the bare ":else".`);
|
|
601
|
+
falsy.push(`:if ${elseIf[1]}`); // desugar the chain tail into a nested :if
|
|
602
|
+
current = falsy;
|
|
603
|
+
mode = "elseif";
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
if (t === ":else") {
|
|
607
|
+
if (mode === "else") throw new Error(`Duplicate ":else" in ${file}. A conditional may have only one bare ":else".`);
|
|
608
|
+
current = falsy;
|
|
609
|
+
mode = "else";
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
569
612
|
}
|
|
570
613
|
current.push(line);
|
|
571
614
|
}
|
|
@@ -623,20 +666,44 @@ function parseIncludeArgs(raw, ctx) {
|
|
|
623
666
|
* @returns {string}
|
|
624
667
|
*/
|
|
625
668
|
function handleContainer(header, bodyLines, ctx) {
|
|
626
|
-
|
|
669
|
+
let rest = header.trim();
|
|
627
670
|
let tag = "section";
|
|
628
|
-
/** @type {string[]} */
|
|
629
|
-
|
|
671
|
+
/** @type {string[]} Static classes baked into class="". */
|
|
672
|
+
const extraClass = [];
|
|
673
|
+
/** @type {[string, string][]} Reactive loop-item class bindings (data-wd-each-class). */
|
|
674
|
+
const eachClasses = [];
|
|
675
|
+
/** @type {[string, string][]} Global state-driven class bindings (data-wd-class). */
|
|
676
|
+
const stateClasses = [];
|
|
630
677
|
let id = "";
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
678
|
+
// Leading tag/name token (anything not starting with . or #). "section" keeps
|
|
679
|
+
// the <section> tag; any other name becomes a <div> and also a class.
|
|
680
|
+
const lead = rest.match(/^([^\s.#]\S*)/);
|
|
681
|
+
let nameToken = "section";
|
|
682
|
+
if (lead) { nameToken = lead[1]; rest = rest.slice(lead[0].length).trim(); }
|
|
683
|
+
if (nameToken !== "section") { tag = "div"; extraClass.push(nameToken); }
|
|
684
|
+
while (rest.length) {
|
|
685
|
+
if (rest.startsWith("#")) {
|
|
686
|
+
const m = rest.match(/^#(\S+)/);
|
|
687
|
+
id = (m ? m[1] : "");
|
|
688
|
+
rest = rest.slice((m ? m[0] : rest).length).trim();
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const cm = rest.match(/^\.([A-Za-z_][\w-]*)/);
|
|
692
|
+
if (!cm) throw new Error(`Unexpected token "${rest.split(/\s+/)[0]}" in container "::: ${header}" in ${ctx.file}`);
|
|
693
|
+
const cls = cm[1];
|
|
694
|
+
rest = rest.slice(cm[0].length).trim();
|
|
695
|
+
// Optional `when <predicate>` makes the class reactive. The predicate runs to
|
|
696
|
+
// the next ` .`/` #` token or the end of the header.
|
|
697
|
+
const whenMatch = rest.match(/^when\s+(.+?)(?=\s+[.#]|$)/);
|
|
698
|
+
if (whenMatch) {
|
|
699
|
+
rest = rest.slice(whenMatch[0].length).trim();
|
|
700
|
+
const compiled = compileWhen(whenMatch[1].trim(), ctx);
|
|
701
|
+
if (compiled.static) { if (compiled.value) extraClass.push(cls); }
|
|
702
|
+
else if (compiled.item) eachClasses.push([cls, compiled.body]);
|
|
703
|
+
else stateClasses.push([cls, compiled.body]);
|
|
704
|
+
} else {
|
|
705
|
+
extraClass.push(cls);
|
|
706
|
+
}
|
|
640
707
|
}
|
|
641
708
|
const explicitId = Boolean(id);
|
|
642
709
|
if (!id) id = `wd-s${++ctx.comp.sectionCounter}`;
|
|
@@ -650,7 +717,10 @@ function handleContainer(header, bodyLines, ctx) {
|
|
|
650
717
|
}
|
|
651
718
|
const idAttr = explicitId ? ` id="${escapeHtml(id)}"` : "";
|
|
652
719
|
const classAttr = extraClass.length ? ` class="${escapeHtml(extraClass.join(" "))}"` : "";
|
|
653
|
-
|
|
720
|
+
let classBind = "";
|
|
721
|
+
if (eachClasses.length) { ctx.comp.assets.runtime = true; classBind += ` data-wd-each-class="${escapeHtml(JSON.stringify(eachClasses))}"`; }
|
|
722
|
+
if (stateClasses.length) { ctx.comp.assets.runtime = true; classBind += ` data-wd-class="${escapeHtml(JSON.stringify(stateClasses))}"`; }
|
|
723
|
+
return `<${tag}${idAttr}${classAttr}${classBind}>\n${inner}\n</${tag}>`;
|
|
654
724
|
}
|
|
655
725
|
|
|
656
726
|
/**
|
|
@@ -729,7 +799,7 @@ function declareErrorState(key, ctx) {
|
|
|
729
799
|
}
|
|
730
800
|
|
|
731
801
|
const FETCH_USE =
|
|
732
|
-
'Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key]';
|
|
802
|
+
'Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key] [refresh=url]';
|
|
733
803
|
|
|
734
804
|
/**
|
|
735
805
|
* Parse a keyword-arg `:fetch` directive into a lifecycle-aware marker.
|
|
@@ -744,14 +814,14 @@ function handleFetch(line, ctx) {
|
|
|
744
814
|
const head = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+)\s*(.*)$/);
|
|
745
815
|
if (!head) throw new Error(`Malformed :fetch in ${ctx.file}: ${line}. ${FETCH_USE}`);
|
|
746
816
|
const name = head[1];
|
|
747
|
-
const url = stripQuotes(head[2]);
|
|
817
|
+
const url = validateFetchUrl(stripQuotes(head[2]), ctx);
|
|
748
818
|
/** @type {Record<string, string>} */
|
|
749
819
|
const opts = {};
|
|
750
820
|
for (const part of head[3].trim().split(/\s+/).filter(Boolean)) {
|
|
751
821
|
const kv = part.match(/^([A-Za-z]+)=(.+)$/);
|
|
752
822
|
if (!kv) throw new Error(`Unknown :fetch option "${part}" in ${ctx.file}. ${FETCH_USE}`);
|
|
753
823
|
const optName = kv[1];
|
|
754
|
-
if (!["method", "when", "timeout", "retry", "headers", "body"].includes(optName)) {
|
|
824
|
+
if (!["method", "when", "timeout", "retry", "headers", "body", "refresh"].includes(optName)) {
|
|
755
825
|
throw new Error(`Unknown :fetch option "${optName}" in ${ctx.file}. ${FETCH_USE}`);
|
|
756
826
|
}
|
|
757
827
|
opts[optName] = stripQuotes(kv[2]);
|
|
@@ -768,6 +838,14 @@ function handleFetch(line, ctx) {
|
|
|
768
838
|
throw new Error(`:fetch ${n} must be a non-negative integer in ${ctx.file}. ${FETCH_USE}`);
|
|
769
839
|
}
|
|
770
840
|
}
|
|
841
|
+
if (opts.refresh !== undefined) {
|
|
842
|
+
// Layer 2: a 401 triggers a token-refresh POST to this URL, then one retry.
|
|
843
|
+
// The new token is written back into the `headers=` state, so it is required.
|
|
844
|
+
if (!opts.headers) {
|
|
845
|
+
throw new Error(`:fetch refresh= needs headers= (the state key holding the token to renew) in ${ctx.file}. ${FETCH_USE}`);
|
|
846
|
+
}
|
|
847
|
+
opts.refresh = validateFetchUrl(opts.refresh, ctx, ":fetch refresh");
|
|
848
|
+
}
|
|
771
849
|
|
|
772
850
|
// Extract `{ path }` dependency keys from the URL (validated against poison
|
|
773
851
|
// segments). The runtime fills them from state on each (re)fetch.
|
|
@@ -800,11 +878,40 @@ function handleFetch(line, ctx) {
|
|
|
800
878
|
attr("retry", opts.retry) +
|
|
801
879
|
attr("headers", opts.headers && resolveStateKey(opts.headers, ctx)) +
|
|
802
880
|
attr("body", opts.body && resolveStateKey(opts.body, ctx)) +
|
|
881
|
+
attr("refresh", opts.refresh) +
|
|
803
882
|
attr("deps", deps.join(",")) +
|
|
804
883
|
`></span>`;
|
|
805
884
|
return `<script type="application/json" data-wd-state>${safeScriptJson(seeds)}</script>${marker}`;
|
|
806
885
|
}
|
|
807
886
|
|
|
887
|
+
/**
|
|
888
|
+
* Validate a `:fetch` (or `refresh=`) URL's scheme at compile time. Mirrors the
|
|
889
|
+
* `:try` href guard: relative paths (`/`, `./`, `../`, bare), an `http(s)://`
|
|
890
|
+
* URL, or a leading `{ state }` interpolation are allowed; a protocol-relative
|
|
891
|
+
* `//host` or any non-http(s) scheme (`file:`, `data:`, `javascript:`, …) is
|
|
892
|
+
* rejected. Interpolated values are percent-encoded by the runtime, so a scheme
|
|
893
|
+
* cannot be injected through state at request time.
|
|
894
|
+
* @param {string} url
|
|
895
|
+
* @param {Ctx} ctx
|
|
896
|
+
* @param {string} [what] Option name for the error message.
|
|
897
|
+
* @returns {string}
|
|
898
|
+
*/
|
|
899
|
+
function validateFetchUrl(url, ctx, what = ":fetch") {
|
|
900
|
+
const value = url.trim();
|
|
901
|
+
if (value !== url || value === "" || /[\u0000-\u001F\u007F]/.test(value)) {
|
|
902
|
+
throw new Error(`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Use a relative path (/, ./, ../), an http(s):// URL, or a { state } interpolation.`);
|
|
903
|
+
}
|
|
904
|
+
if (value.startsWith("//")) {
|
|
905
|
+
throw new Error(`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http:// or https:// explicitly.`);
|
|
906
|
+
}
|
|
907
|
+
if (value.startsWith("{")) return value; // interpolation-first; the runtime percent-encodes state values
|
|
908
|
+
const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
|
|
909
|
+
if (scheme && !["http", "https"].includes(scheme[1].toLowerCase())) {
|
|
910
|
+
throw new Error(`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. The "${scheme[1]}:" scheme is not allowed; use http://, https://, or a relative path.`);
|
|
911
|
+
}
|
|
912
|
+
return value;
|
|
913
|
+
}
|
|
914
|
+
|
|
808
915
|
/**
|
|
809
916
|
* @param {string} line
|
|
810
917
|
* @param {Ctx} ctx
|
|
@@ -1020,6 +1127,30 @@ function handleButton(line, ctx) {
|
|
|
1020
1127
|
return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
|
|
1021
1128
|
}
|
|
1022
1129
|
|
|
1130
|
+
const EFFECT_USE = "Use: :effect watchedState -> action[; action…] (actions use the :button vocabulary).";
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Parse `:effect <watched> -> <actions>` into a zero-output marker the runtime
|
|
1134
|
+
* watches: when `<watched>` state changes, it runs `<actions>` (the same `:button`
|
|
1135
|
+
* action vocabulary, `;`-chained). For arbitrary side effects beyond `:computed`
|
|
1136
|
+
* (derive state) and fetch deps (auto-refetch).
|
|
1137
|
+
* @param {string} line
|
|
1138
|
+
* @param {Ctx} ctx
|
|
1139
|
+
* @returns {string}
|
|
1140
|
+
*/
|
|
1141
|
+
function handleEffect(line, ctx) {
|
|
1142
|
+
const match = line.match(/^:effect\s+([A-Za-z_$][\w$.]*)\s*->\s*(.+)$/);
|
|
1143
|
+
if (!match) throw new Error(`Malformed :effect in ${ctx.file}: ${line}. ${EFFECT_USE}`);
|
|
1144
|
+
const segs = validatePath(match[1], ctx, EFFECT_USE);
|
|
1145
|
+
const key = resolveStateKey(segs[0], ctx);
|
|
1146
|
+
if (!key) throw new Error(`:effect watches unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`);
|
|
1147
|
+
ctx.comp.assets.runtime = true;
|
|
1148
|
+
const watch = [key, ...segs.slice(1)].join(".");
|
|
1149
|
+
const action = parseAction(match[2], ctx);
|
|
1150
|
+
const actions = Array.isArray(action) ? action : [action];
|
|
1151
|
+
return `<script type="application/json" data-wd-effect>${safeScriptJson({ watch, actions })}</script>`;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1023
1154
|
/**
|
|
1024
1155
|
* @param {string} line
|
|
1025
1156
|
* @param {string[]} truthyLines
|
|
@@ -1364,6 +1495,75 @@ function evalPredicate(body, item, ctx) {
|
|
|
1364
1495
|
}
|
|
1365
1496
|
}
|
|
1366
1497
|
|
|
1498
|
+
/**
|
|
1499
|
+
* Compile one operand of a `::: … .class when <predicate>` expression. Like the
|
|
1500
|
+
* `@loop where` operand, but also folds a static-scope value (a loop-unrolled
|
|
1501
|
+
* item field or include arg) to its build-time literal, so a fully-static
|
|
1502
|
+
* predicate can be evaluated at compile time. Mirrors `:if`'s scope→item→state
|
|
1503
|
+
* resolution order.
|
|
1504
|
+
* @param {string} tok
|
|
1505
|
+
* @param {Ctx} ctx
|
|
1506
|
+
* @returns {{ code: string, state: boolean, item: boolean }}
|
|
1507
|
+
*/
|
|
1508
|
+
function compileWhenOperand(tok, ctx) {
|
|
1509
|
+
if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok)) return { code: JSON.stringify(tok.slice(1, -1)), state: false, item: false };
|
|
1510
|
+
if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, state: false, item: false };
|
|
1511
|
+
if (["true", "false", "null"].includes(tok)) return { code: tok, state: false, item: false };
|
|
1512
|
+
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
|
|
1513
|
+
throw new Error(`Unsupported operand "${tok}" in "::: … when" (${ctx.file}). Use item.field, a :state name, a number, or a "string".`);
|
|
1514
|
+
}
|
|
1515
|
+
const segs = tok.split(".");
|
|
1516
|
+
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
1517
|
+
throw new Error(`Path "${tok}" is not allowed in "::: … when" (${ctx.file})`);
|
|
1518
|
+
}
|
|
1519
|
+
const scoped = lookupVar(ctx.scope, segs[0]);
|
|
1520
|
+
if (scoped.found) return { code: JSON.stringify(getPath(scoped.value, segs.slice(1)) ?? null), state: false, item: false };
|
|
1521
|
+
if (ctx.loopItem && segs[0] === ctx.loopItem) return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, state: false, item: true };
|
|
1522
|
+
const key = resolveStateKey(segs[0], ctx);
|
|
1523
|
+
if (!key) throw new Error(`"::: … when" references unknown name "${segs[0]}" in ${ctx.file}. Use a loop item field, a declared :state, a number, or a "string".`);
|
|
1524
|
+
const rest = segs.slice(1).join(".");
|
|
1525
|
+
return { code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`, state: true, item: false };
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/**
|
|
1529
|
+
* Compile a `::: … .class when <predicate>`. Allows `:if`-style bare truthy
|
|
1530
|
+
* paths and `where`-style `left <op> right` conditions joined by `and`/`or`.
|
|
1531
|
+
* Folds to a static verdict when every operand is build-known, else returns a
|
|
1532
|
+
* runtime body over I()/S()/C() plus whether it reads the reactive loop item
|
|
1533
|
+
* (which decides data-wd-each-class vs the global data-wd-class).
|
|
1534
|
+
* @param {string} raw
|
|
1535
|
+
* @param {Ctx} ctx
|
|
1536
|
+
* @returns {{ static: true, value: boolean } | { static: false, body: string, item: boolean }}
|
|
1537
|
+
*/
|
|
1538
|
+
function compileWhen(raw, ctx) {
|
|
1539
|
+
const parts = raw.split(/\s+(and|or)\s+/i);
|
|
1540
|
+
/** @type {string[]} */
|
|
1541
|
+
const pieces = [];
|
|
1542
|
+
let usesState = false;
|
|
1543
|
+
let usesItem = false;
|
|
1544
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1545
|
+
if (i % 2 === 1) { pieces.push(parts[i].toLowerCase() === "and" ? "&&" : "||"); continue; }
|
|
1546
|
+
const seg = parts[i].trim();
|
|
1547
|
+
const opMatch = seg.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
|
|
1548
|
+
if (opMatch) {
|
|
1549
|
+
const left = compileWhenOperand(opMatch[1].trim(), ctx);
|
|
1550
|
+
const right = compileWhenOperand(opMatch[3].trim(), ctx);
|
|
1551
|
+
usesState = usesState || left.state || right.state;
|
|
1552
|
+
usesItem = usesItem || left.item || right.item;
|
|
1553
|
+
const op = opMatch[2].toLowerCase();
|
|
1554
|
+
pieces.push(op === "contains" ? `(C(${left.code}, ${right.code}))` : `(${left.code} ${op} ${right.code})`);
|
|
1555
|
+
} else {
|
|
1556
|
+
const only = compileWhenOperand(seg, ctx);
|
|
1557
|
+
usesState = usesState || only.state;
|
|
1558
|
+
usesItem = usesItem || only.item;
|
|
1559
|
+
pieces.push(`(${only.code})`);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
const body = pieces.join(" ");
|
|
1563
|
+
if (!usesState && !usesItem) return { static: true, value: evalPredicate(body, undefined, ctx) };
|
|
1564
|
+
return { static: false, body, item: usesItem };
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1367
1567
|
/**
|
|
1368
1568
|
* @param {unknown[]} rows
|
|
1369
1569
|
* @param {string} itemName
|
package/src/runtime.js
CHANGED
|
@@ -131,7 +131,11 @@ function isEmpty(v) {
|
|
|
131
131
|
async function httpJson(url, init) {
|
|
132
132
|
const response = await fetch(url, init);
|
|
133
133
|
const text = await response.text();
|
|
134
|
-
if (!response.ok)
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
const err = /** @type {Error & { status?: number }} */ (new Error(`HTTP ${response.status}`));
|
|
136
|
+
err.status = response.status; // lets the :fetch lifecycle single out a 401 for token refresh
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
135
139
|
try { return JSON.parse(text); } catch { return { status: response.status, body: text }; }
|
|
136
140
|
}
|
|
137
141
|
|
|
@@ -186,6 +190,29 @@ function loopPredicate(body) {
|
|
|
186
190
|
return fn;
|
|
187
191
|
}
|
|
188
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Toggle reactive `.class when <predicate>` bindings on an element. `raw` is the
|
|
195
|
+
* JSON `[[name, body], …]` from data-wd-class (global, item undefined) or
|
|
196
|
+
* data-wd-each-class (per loop row); each body is the same I()/S()/C()
|
|
197
|
+
* expression the loop `where` uses.
|
|
198
|
+
* @param {Element} el
|
|
199
|
+
* @param {string | null} raw
|
|
200
|
+
* @param {any} item Loop item for each-class; undefined for a global class.
|
|
201
|
+
* @returns {void}
|
|
202
|
+
*/
|
|
203
|
+
function classToggle(el, raw, item) {
|
|
204
|
+
if (!raw) return;
|
|
205
|
+
/** @type {[string, string][]} */
|
|
206
|
+
let pairs;
|
|
207
|
+
try { pairs = JSON.parse(raw); } catch { return; }
|
|
208
|
+
for (const [name, body] of pairs) {
|
|
209
|
+
let on = false;
|
|
210
|
+
try { on = Boolean(loopPredicate(body)((/** @type {string} */ p) => getPath(item, p), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn)); }
|
|
211
|
+
catch (error) { warn(body, error); }
|
|
212
|
+
el.classList.toggle(name, on);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
189
216
|
/**
|
|
190
217
|
* Stable per-render key for a loop row, disambiguating duplicates with `#n`.
|
|
191
218
|
* @param {any} item
|
|
@@ -231,6 +258,8 @@ function fillItem(node, item, meta = {}) {
|
|
|
231
258
|
for (const marker of node.querySelectorAll("[data-wd-each-meta]")) {
|
|
232
259
|
marker.textContent = meta[marker.getAttribute("data-wd-each-meta") || ""] ?? "";
|
|
233
260
|
}
|
|
261
|
+
const classNodes = node.matches("[data-wd-each-class]") ? [node, ...node.querySelectorAll("[data-wd-each-class]")] : [...node.querySelectorAll("[data-wd-each-class]")];
|
|
262
|
+
for (const el of classNodes) classToggle(el, el.getAttribute("data-wd-each-class"), item);
|
|
234
263
|
}
|
|
235
264
|
|
|
236
265
|
/**
|
|
@@ -290,6 +319,10 @@ function renderNow() {
|
|
|
290
319
|
output.innerHTML = template?.innerHTML || "";
|
|
291
320
|
}
|
|
292
321
|
|
|
322
|
+
// Reactive styling: toggle state-driven `.class when …` bindings. Loop-row
|
|
323
|
+
// bindings (data-wd-each-class) are handled per item inside fillItem.
|
|
324
|
+
for (const el of document.querySelectorAll("[data-wd-class]")) classToggle(el, el.getAttribute("data-wd-class"), undefined);
|
|
325
|
+
|
|
293
326
|
for (const region of document.querySelectorAll("[data-wd-loop]")) {
|
|
294
327
|
const key = region.getAttribute("data-wd-loop");
|
|
295
328
|
const data = region.getAttribute("data-wd-loop-data");
|
|
@@ -362,6 +395,36 @@ function renderNow() {
|
|
|
362
395
|
for (const input of document.querySelectorAll("[data-wd-bind-input]")) {
|
|
363
396
|
if (document.activeElement !== input) /** @type {HTMLInputElement} */ (input).value = state[input.getAttribute("data-wd-bind-input") || ""] ?? "";
|
|
364
397
|
}
|
|
398
|
+
|
|
399
|
+
runEffects(); // side effects run last, against fully settled state
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** @typedef {{ watch: string, actions: { op: string, target?: string, value?: any }[], last: string }} Effect */
|
|
403
|
+
/** @type {Effect[]} */
|
|
404
|
+
const effects = [...document.querySelectorAll("script[data-wd-effect]")].map((node) => {
|
|
405
|
+
const def = JSON.parse(node.textContent || "{}");
|
|
406
|
+
return { watch: def.watch, actions: def.actions || [], last: JSON.stringify(getPath(state, def.watch) ?? null) };
|
|
407
|
+
});
|
|
408
|
+
let effectDepth = 0;
|
|
409
|
+
/**
|
|
410
|
+
* After a render, fire any `:effect` whose watched state changed, running its
|
|
411
|
+
* actions through the shared action runner. If that mutates state, re-render and
|
|
412
|
+
* re-check; a settle cap of 10 passes guards against effect→effect loops.
|
|
413
|
+
* @returns {void}
|
|
414
|
+
*/
|
|
415
|
+
function runEffects() {
|
|
416
|
+
if (!effects.length) return;
|
|
417
|
+
let changed = false;
|
|
418
|
+
for (const fx of effects) {
|
|
419
|
+
const now = JSON.stringify(getPath(state, fx.watch) ?? null);
|
|
420
|
+
if (now === fx.last) continue;
|
|
421
|
+
fx.last = now;
|
|
422
|
+
for (const a of fx.actions) applyAction(null, a.op, a.target || "", a.value);
|
|
423
|
+
changed = true;
|
|
424
|
+
}
|
|
425
|
+
if (!changed) { effectDepth = 0; return; }
|
|
426
|
+
if (++effectDepth >= 10) { effectDepth = 0; console.warn("effect did not settle"); return; }
|
|
427
|
+
renderNow(); // reflect the effect's state changes, then re-check
|
|
365
428
|
}
|
|
366
429
|
|
|
367
430
|
/**
|
|
@@ -391,10 +454,11 @@ document.addEventListener("input", (event) => {
|
|
|
391
454
|
/**
|
|
392
455
|
* Resolve the loop source and row item for a clicked element, if inside a loop.
|
|
393
456
|
* The clicked button's row is the nearest reconciled loop node; it carries its item.
|
|
394
|
-
* @param {Element} el
|
|
457
|
+
* @param {Element | null} el Null for effect-driven actions (no clicked row).
|
|
395
458
|
* @returns {{ srcKey: string | null, item: any } | null}
|
|
396
459
|
*/
|
|
397
460
|
function clickedRow(el) {
|
|
461
|
+
if (!el) return null; // effect-driven actions have no clicked row → row ops no-op
|
|
398
462
|
const row = /** @type {WdRow | null} */ (el.closest("[data-wd-loop-key]"));
|
|
399
463
|
const region = el.closest("[data-wd-loop]");
|
|
400
464
|
if (!row || !region) return null;
|
|
@@ -404,7 +468,7 @@ function clickedRow(el) {
|
|
|
404
468
|
/**
|
|
405
469
|
* Apply one parsed action `{op,target,value}` against state. Targets are dotted
|
|
406
470
|
* paths read via getPath / written via setPath; a bare name is a 1-segment path.
|
|
407
|
-
* @param {Element} action The clicked element (for loop-row resolution).
|
|
471
|
+
* @param {Element | null} action The clicked element (for loop-row resolution); null for effects.
|
|
408
472
|
* @param {string} op
|
|
409
473
|
* @param {string} target
|
|
410
474
|
* @param {any} value
|
|
@@ -518,12 +582,17 @@ function startFetch(node) {
|
|
|
518
582
|
|
|
519
583
|
const headers = g("headers");
|
|
520
584
|
const body = g("body");
|
|
585
|
+
const refresh = g("refresh");
|
|
521
586
|
const method = g("method") || "GET";
|
|
522
587
|
const timeout = Number(g("timeout"));
|
|
523
588
|
let tries = Number(g("retry"));
|
|
589
|
+
let refreshed = false; // a token refresh + retry happens at most once per request
|
|
524
590
|
state[key + "_error"] = null;
|
|
525
591
|
set("_loading", true);
|
|
526
592
|
|
|
593
|
+
/** @param {any} error */
|
|
594
|
+
const fail = (error) => { state[key + "_error"] = String(error); set("_loading", false); };
|
|
595
|
+
|
|
527
596
|
const attempt = () => {
|
|
528
597
|
/** @type {RequestInit} */
|
|
529
598
|
const init = { method };
|
|
@@ -542,14 +611,53 @@ function startFetch(node) {
|
|
|
542
611
|
}).catch((error) => {
|
|
543
612
|
clearTimeout(timer);
|
|
544
613
|
if (dead()) return;
|
|
614
|
+
const is401 = error && error.status === 401;
|
|
615
|
+
// Layer 2: a 401 with a refresh URL renews the token once, then retries.
|
|
616
|
+
if (is401 && refresh && headers && !refreshed) {
|
|
617
|
+
refreshed = true;
|
|
618
|
+
return void refreshToken(refresh, headers).then((ok) => {
|
|
619
|
+
if (dead()) return;
|
|
620
|
+
ok ? attempt() : fail(error);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
if (is401 && refreshed) return void fail(error); // a second 401 after refresh → give up
|
|
545
624
|
if (tries-- > 0) return void setTimeout(attempt, 200);
|
|
546
|
-
|
|
547
|
-
set("_loading", false);
|
|
625
|
+
fail(error);
|
|
548
626
|
});
|
|
549
627
|
};
|
|
550
628
|
attempt();
|
|
551
629
|
}
|
|
552
630
|
|
|
631
|
+
/** @type {Map<string, Promise<boolean>>} In-flight token refreshes, keyed by URL. */
|
|
632
|
+
const refreshFlights = new Map();
|
|
633
|
+
/**
|
|
634
|
+
* Single-flight token refresh: POST the current header state to `refreshUrl`,
|
|
635
|
+
* and on a 2xx JSON reply write the returned headers object back into the
|
|
636
|
+
* `headersKey` state (persisting if it is a `:store`). Concurrent 401s sharing a
|
|
637
|
+
* refresh URL await the same request, then each retries its own fetch.
|
|
638
|
+
* @param {string} refreshUrl
|
|
639
|
+
* @param {string} headersKey
|
|
640
|
+
* @returns {Promise<boolean>} whether the token was renewed
|
|
641
|
+
*/
|
|
642
|
+
function refreshToken(refreshUrl, headersKey) {
|
|
643
|
+
const inflight = refreshFlights.get(refreshUrl);
|
|
644
|
+
if (inflight) return inflight;
|
|
645
|
+
const flight = httpJson(refreshUrl, {
|
|
646
|
+
method: "POST",
|
|
647
|
+
headers: { "content-type": "application/json" },
|
|
648
|
+
body: JSON.stringify(state[headersKey] ?? {})
|
|
649
|
+
}).then((value) => {
|
|
650
|
+
if (value && typeof value === "object") {
|
|
651
|
+
state[headersKey] = value; // the new headers shape, e.g. { Authorization: "Bearer …" }
|
|
652
|
+
savePersisted();
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
return false;
|
|
656
|
+
}).catch(() => false).finally(() => refreshFlights.delete(refreshUrl));
|
|
657
|
+
refreshFlights.set(refreshUrl, flight);
|
|
658
|
+
return flight;
|
|
659
|
+
}
|
|
660
|
+
|
|
553
661
|
/** A fetch marker carrying its last dependency snapshot for refetch diffing and
|
|
554
662
|
* a generation token so a superseded in-flight response/retry bails on write. */
|
|
555
663
|
/** @typedef {Element & { __wdSnap?: string, __wdGen?: number }} FetchNode */
|