@zvndev/darkmown 2.6.1-next.11.g82bb8d7 → 2.7.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 +392 -77
- package/CHANGELOG.md +47 -0
- package/README.md +316 -49
- package/docs/cli.md +51 -17
- package/docs/spec-alignment.md +25 -4
- package/grammar/wd-directives.gbnf +13 -5
- package/package.json +7 -3
- package/site/_/base.skin +1 -1
- package/site/_/footer.wd +1 -1
- package/site/pages/api-errors/index.wd +81 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/attr-binding/index.wd +84 -0
- package/site/pages/blog/darkmown-2-7-0.md +176 -0
- package/site/pages/blog/darkmown-2-7-0.skin +127 -0
- package/site/pages/blog/no-unsafe-eval.md +66 -0
- package/site/pages/blog/no-unsafe-eval.skin +127 -0
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/branch-state/index.wd +68 -0
- package/site/pages/docs/index.wd +320 -19
- package/site/pages/index.wd +3 -1
- package/site/pages/playground/index.js +63 -56
- package/site/pages/pulse/index.wd +1 -1
- package/site/pages/settings/index.wd +97 -0
- package/site/pages/showcase/index.wd +37 -1
- package/site/pages/upload/index.wd +63 -0
- package/site/pages/url-state/index.wd +70 -0
- package/src/builder.js +222 -34
- package/src/catalog.js +217 -21
- package/src/cli.js +6 -3
- package/src/compiler/actions.js +80 -12
- package/src/compiler/body.js +104 -28
- package/src/compiler/collections.js +29 -13
- package/src/compiler/context.js +9 -2
- package/src/compiler/expr-ast.js +14 -0
- package/src/compiler/fetch.js +8 -4
- package/src/compiler/forms.js +133 -14
- package/src/compiler/frontmatter.js +28 -7
- package/src/compiler/includes.js +46 -6
- package/src/compiler/interpolation.js +56 -1
- package/src/compiler/loops.js +489 -35
- package/src/compiler/markdown.js +457 -44
- package/src/compiler/page.js +8 -3
- package/src/compiler/predicates.js +84 -14
- package/src/compiler/state.js +167 -18
- package/src/compiler/structure.js +159 -29
- package/src/errors.js +111 -3
- package/src/feeds.js +140 -7
- package/src/grammar.js +53 -10
- package/src/runtime.js +509 -130
- package/src/runtime.min.js +2 -0
- package/src/runtime.min.js.map +1 -0
- package/src/statics.js +46 -2
- package/src/templates/blog/README.md +2 -0
- package/src/templates/dashboard/README.md +2 -0
- package/src/templates/landing/README.md +2 -0
- package/src/templates/starter/README.md +1 -1
- package/src/templates/store/README.md +2 -0
- package/src/tools/validate.js +14 -4
- package/types/builder.d.ts +6 -2
- package/types/catalog.d.ts +12 -0
- package/types/compiler/actions.d.ts +1 -1
- package/types/compiler/body.d.ts +0 -3
- package/types/compiler/forms.d.ts +12 -1
- package/types/compiler/frontmatter.d.ts +8 -1
- package/types/compiler/includes.d.ts +16 -2
- package/types/compiler/interpolation.d.ts +23 -0
- package/types/compiler/loops.d.ts +47 -0
- package/types/compiler/markdown.d.ts +63 -1
- package/types/compiler/predicates.d.ts +7 -1
- package/types/compiler/state.d.ts +8 -2
- package/types/compiler/structure.d.ts +32 -1
- package/types/feeds.d.ts +68 -2
- package/types/statics.d.ts +25 -0
package/src/compiler/forms.js
CHANGED
|
@@ -39,6 +39,49 @@ export const BIND_EXAMPLE = ':bind query placeholder="Search"';
|
|
|
39
39
|
export const SLIDER_EXAMPLE = ":slider volume = 50 min=0 max=100 step=1";
|
|
40
40
|
const INPUT_USE = `Use: :input name [type=…] [placeholder="…"] [required] — e.g. ${INPUT_EXAMPLE}`;
|
|
41
41
|
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// A FIELD DIRECTIVE MEANS TWO DIFFERENT THINGS, AND ONLY THE FORM KNOWS WHICH.
|
|
44
|
+
//
|
|
45
|
+
// Inside a `:form`, `:select topic` names a FORM FIELD: the label goes in the
|
|
46
|
+
// submitted payload under that name and nothing is reactive. Outside one, the
|
|
47
|
+
// same line is a bound CONTROL over a declared `:state`, exactly like `:bind`
|
|
48
|
+
// and `:slider` — changing it writes the state, changing the state moves it.
|
|
49
|
+
//
|
|
50
|
+
// The two cannot be told apart from the line alone, and inferring it from
|
|
51
|
+
// "does a state of that name happen to exist?" would mean declaring an
|
|
52
|
+
// unrelated `:state topic` elsewhere on the page silently rewires a form. So
|
|
53
|
+
// the enclosing `:form` says so: `handleForm` flags the context it compiles its
|
|
54
|
+
// body with, and `nestedCtx` spreads that flag into every nested block, so a
|
|
55
|
+
// field inside a `:::` inside the form still sees it.
|
|
56
|
+
//
|
|
57
|
+
// The flag rides on the context object rather than the `Ctx` typedef because
|
|
58
|
+
// `context.js` is the root of the module DAG and knows nothing about forms.
|
|
59
|
+
// (Known limit: an `@include` compiles its target with a fresh context, so a
|
|
60
|
+
// field directive inside an included partial is always the standalone form.)
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/** @param {Ctx} ctx @returns {boolean} */
|
|
64
|
+
const inForm = (ctx) => Boolean(/** @type {any} */ (ctx).inForm);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a standalone field's state key, or explain how to declare it.
|
|
68
|
+
* @param {string} name
|
|
69
|
+
* @param {string} directive `:select` / `:checkbox` / `:radio`.
|
|
70
|
+
* @param {Ctx} ctx
|
|
71
|
+
* @param {number} index 0-based line index for `file:line`.
|
|
72
|
+
* @returns {string} The fully-qualified state key.
|
|
73
|
+
*/
|
|
74
|
+
function boundKey(name, directive, ctx, index) {
|
|
75
|
+
const key = resolveStateKey(name, ctx);
|
|
76
|
+
if (key) return key;
|
|
77
|
+
const seed = directive === ":checkbox" ? "false" : '"…"';
|
|
78
|
+
const hint = `declare the state first — :state ${name} = ${seed} — or move ${directive} inside a :form, where the name is a form field instead`;
|
|
79
|
+
throw wdError(
|
|
80
|
+
`${directive} ${name} in ${at(ctx, index)} is outside a :form and has no matching state. Use: ${hint} — e.g. ${SELECT_EXAMPLE}`,
|
|
81
|
+
{ code: "WD450", file: ctx.file, line: lineOf(ctx, index), hint, example: SELECT_EXAMPLE }
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
42
85
|
/**
|
|
43
86
|
* @param {string} line
|
|
44
87
|
* @param {string[]} bodyLines
|
|
@@ -67,9 +110,30 @@ export function handleForm(line, bodyLines, ctx, index) {
|
|
|
67
110
|
});
|
|
68
111
|
}
|
|
69
112
|
const action = rawAction ? validateFetchUrl(rawAction, ctx, ":form action") : undefined;
|
|
70
|
-
|
|
113
|
+
// See the `inForm` note above: the body's context carries the flag that makes
|
|
114
|
+
// a field directive a FORM FIELD rather than a bound control.
|
|
115
|
+
const bodyCtx = /** @type {any} */ (nestedCtx(ctx, index + 1));
|
|
116
|
+
bodyCtx.inForm = true;
|
|
117
|
+
const inner = ctx.compileBody(bodyLines, bodyCtx).trim();
|
|
118
|
+
// A file cannot be urlencoded, so a form carrying one has to be multipart —
|
|
119
|
+
// in the browser's native submit (this attribute) and in the runtime's
|
|
120
|
+
// round-trip (which posts the FormData itself). A GET form has no body at
|
|
121
|
+
// all, so the file would be dropped and only its NAME would travel: that is
|
|
122
|
+
// the silent kind of failure, so it is a compile error instead.
|
|
123
|
+
const hasFile = /<input[^>]*\btype="file"/i.test(inner);
|
|
124
|
+
if (hasFile && method.toLowerCase() === "get") {
|
|
125
|
+
const hint = `drop method="get" (a :form posts by default) — a GET request has no body, so the file would never be sent — e.g. ${FORM_EXAMPLE}`;
|
|
126
|
+
throw wdError(`:form in ${at(ctx, index)} has a file field and method="get". Use: ${hint}`, {
|
|
127
|
+
code: "WD452",
|
|
128
|
+
file: ctx.file,
|
|
129
|
+
line: lineOf(ctx, index),
|
|
130
|
+
hint,
|
|
131
|
+
example: FORM_EXAMPLE
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const enctype = hasFile ? ` enctype="multipart/form-data"` : "";
|
|
71
135
|
if (!into) {
|
|
72
|
-
return `<form action="${escapeHtml(action)}" method="${escapeHtml(method)}">${inner}</form>`;
|
|
136
|
+
return `<form action="${escapeHtml(action)}" method="${escapeHtml(method)}"${enctype}>${inner}</form>`;
|
|
73
137
|
}
|
|
74
138
|
const key = declareState(into, null, ctx);
|
|
75
139
|
declareErrorState(key, ctx);
|
|
@@ -80,9 +144,9 @@ export function handleForm(line, bodyLines, ctx, index) {
|
|
|
80
144
|
detail: `:form action="${rawAction}" into ${into}`
|
|
81
145
|
});
|
|
82
146
|
const actionAttrs = action
|
|
83
|
-
? ` action="${escapeHtml(action)}" method="${escapeHtml(method)}"`
|
|
147
|
+
? ` action="${escapeHtml(action)}" method="${escapeHtml(method)}"${enctype}`
|
|
84
148
|
: "";
|
|
85
|
-
return `<script type="application/json" data-wd-state>${safeScriptJson({ [key]: null })}</script><form data-wd-form="${key}"${actionAttrs}>${inner}</form>`;
|
|
149
|
+
return `<script type="application/json" data-wd-state>${safeScriptJson({ [key]: null })}</script><form data-wd-form="${escapeHtml(key)}"${actionAttrs}>${inner}</form>`;
|
|
86
150
|
}
|
|
87
151
|
|
|
88
152
|
/**
|
|
@@ -230,7 +294,7 @@ export function handleBind(line, ctx, index) {
|
|
|
230
294
|
const initial = ctx.comp.state.get(key);
|
|
231
295
|
const valueAttr =
|
|
232
296
|
initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
|
|
233
|
-
return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
|
|
297
|
+
return `<input type="${escapeHtml(type)}" data-wd-bind-input="${escapeHtml(key)}"${valueAttr} ${attrs.join(" ")}>`;
|
|
234
298
|
}
|
|
235
299
|
|
|
236
300
|
/**
|
|
@@ -318,7 +382,7 @@ export function handleSlider(line, ctx, index) {
|
|
|
318
382
|
{ code: "WD413", file: ctx.file, line: lineOf(ctx, index) }
|
|
319
383
|
);
|
|
320
384
|
key = declareState(name, value, ctx);
|
|
321
|
-
const persistAttr = persist ? ` data-wd-persist="${key}"` : "";
|
|
385
|
+
const persistAttr = persist ? ` data-wd-persist="${escapeHtml(key)}"` : "";
|
|
322
386
|
seed = `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
|
|
323
387
|
} else {
|
|
324
388
|
if (persist)
|
|
@@ -340,7 +404,7 @@ export function handleSlider(line, ctx, index) {
|
|
|
340
404
|
initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
|
|
341
405
|
const ariaAttr = ` aria-label="${escapeHtml(ariaLabel || humanizeName(name))}"`;
|
|
342
406
|
recordSymbol(ctx, index, { kind: "field", name, target: key, detail: `:slider ${name}` });
|
|
343
|
-
return `${seed}<input type="range" data-wd-bind-input="${key}" min="${escapeHtml(min)}" max="${escapeHtml(max)}" step="${escapeHtml(step)}"${valueAttr}${ariaAttr}>`;
|
|
407
|
+
return `${seed}<input type="range" data-wd-bind-input="${escapeHtml(key)}" min="${escapeHtml(min)}" max="${escapeHtml(max)}" step="${escapeHtml(step)}"${valueAttr}${ariaAttr}>`;
|
|
344
408
|
}
|
|
345
409
|
|
|
346
410
|
/**
|
|
@@ -437,7 +501,13 @@ export function handleTextarea(line, ctx, index) {
|
|
|
437
501
|
/**
|
|
438
502
|
* `:select name [required]` followed by `- Label` list lines → a `<select>` with
|
|
439
503
|
* one `<option>` per label (value === label). Derives an aria-label from the
|
|
440
|
-
* humanized name when none is given.
|
|
504
|
+
* humanized name when none is given.
|
|
505
|
+
*
|
|
506
|
+
* Inside a `:form` it is a form field captured by FormData, exactly as before.
|
|
507
|
+
* Outside one it is a control two-way bound to a declared `:state` of that name
|
|
508
|
+
* (see the `inForm` note at the top of this file): the seed selects the matching
|
|
509
|
+
* option, changing the control writes the state, and changing the state moves
|
|
510
|
+
* the control.
|
|
441
511
|
* @param {string} line
|
|
442
512
|
* @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
|
|
443
513
|
* @param {Ctx} ctx
|
|
@@ -456,8 +526,17 @@ export function handleSelect(line, optionLines, ctx, index) {
|
|
|
456
526
|
example: SELECT_EXAMPLE
|
|
457
527
|
});
|
|
458
528
|
}
|
|
459
|
-
const
|
|
460
|
-
|
|
529
|
+
const bound = inForm(ctx) ? null : boundKey(match[1], ":select", ctx, index);
|
|
530
|
+
const attrs = bound
|
|
531
|
+
? [`data-wd-bind-input="${escapeHtml(bound)}"`]
|
|
532
|
+
: [`name="${escapeHtml(match[1])}"`];
|
|
533
|
+
if (bound) ctx.comp.assets.runtime = true;
|
|
534
|
+
recordSymbol(ctx, index, {
|
|
535
|
+
kind: "field",
|
|
536
|
+
name: match[1],
|
|
537
|
+
...(bound ? { target: bound } : {}),
|
|
538
|
+
detail: `:select ${match[1]}`
|
|
539
|
+
});
|
|
461
540
|
let hasAria = false;
|
|
462
541
|
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
463
542
|
for (const token of (match[2] || "").matchAll(re)) {
|
|
@@ -493,8 +572,14 @@ export function handleSelect(line, optionLines, ctx, index) {
|
|
|
493
572
|
{ code: "WD423", file: ctx.file, line: lineOf(ctx, index) }
|
|
494
573
|
);
|
|
495
574
|
}
|
|
575
|
+
// A bound select opens on the state's current value, so the first paint agrees
|
|
576
|
+
// with what the runtime will reflect a moment later.
|
|
577
|
+
const selected = bound ? String(ctx.comp.state.get(bound) ?? "") : null;
|
|
496
578
|
const opts = options
|
|
497
|
-
.map(
|
|
579
|
+
.map(
|
|
580
|
+
(o) =>
|
|
581
|
+
`<option value="${escapeHtml(o)}"${o === selected ? " selected" : ""}>${escapeHtml(o)}</option>`
|
|
582
|
+
)
|
|
498
583
|
.join("");
|
|
499
584
|
return `<select ${attrs.join(" ")}>${opts}</select>`;
|
|
500
585
|
}
|
|
@@ -509,6 +594,11 @@ export function handleSelect(line, optionLines, ctx, index) {
|
|
|
509
594
|
* groups capture a single value like `:input`. Flags: `required` (radio → the
|
|
510
595
|
* group; not emitted on checkboxes, where "at least one" has no native HTML form),
|
|
511
596
|
* `disabled` (whole group), `autofocus` (first control).
|
|
597
|
+
*
|
|
598
|
+
* OUTSIDE a `:form` (see the `inForm` note at the top of this file) both bind to
|
|
599
|
+
* a declared `:state` of that name instead: a `:radio` group binds the STRING of
|
|
600
|
+
* the chosen option, and a `:checkbox` binds a BOOLEAN, so it takes exactly one
|
|
601
|
+
* option — the label to show beside it.
|
|
512
602
|
* @param {string} line
|
|
513
603
|
* @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
|
|
514
604
|
* @param {Ctx} ctx
|
|
@@ -535,7 +625,14 @@ export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
|
|
|
535
625
|
});
|
|
536
626
|
}
|
|
537
627
|
const name = match[1];
|
|
538
|
-
|
|
628
|
+
const bound = inForm(ctx) ? null : boundKey(name, directive, ctx, index);
|
|
629
|
+
if (bound) ctx.comp.assets.runtime = true;
|
|
630
|
+
recordSymbol(ctx, index, {
|
|
631
|
+
kind: "field",
|
|
632
|
+
name,
|
|
633
|
+
...(bound ? { target: bound } : {}),
|
|
634
|
+
detail: `:${kind} ${name}`
|
|
635
|
+
});
|
|
539
636
|
const flags = [];
|
|
540
637
|
let ariaLabel = null;
|
|
541
638
|
let ariaDescribedby = null;
|
|
@@ -570,19 +667,41 @@ export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
|
|
|
570
667
|
);
|
|
571
668
|
}
|
|
572
669
|
const isCheckbox = kind === "checkbox";
|
|
670
|
+
// A bound checkbox is one boolean, so a list of options has no meaning: the
|
|
671
|
+
// author is reaching for a multi-value control that would need every box to
|
|
672
|
+
// write the same key.
|
|
673
|
+
if (bound && isCheckbox && options.length > 1) {
|
|
674
|
+
const hint = `a bound :checkbox is a single true/false, so give it one "- Label" line. For a list of choices, use a :radio group (one value) or drive an array with :button "…" -> ${name} toggle "Label"`;
|
|
675
|
+
throw wdError(
|
|
676
|
+
`:checkbox ${name} in ${at(ctx, index)} is bound to state and has ${options.length} options. Use: ${hint} — e.g. ${CHECKBOX_EXAMPLE}`,
|
|
677
|
+
{ code: "WD451", file: ctx.file, line: lineOf(ctx, index), hint, example: CHECKBOX_EXAMPLE }
|
|
678
|
+
);
|
|
679
|
+
}
|
|
573
680
|
const groupAttrs = [
|
|
574
681
|
`class="wd-${isCheckbox ? "checkboxes" : "radios"}"`,
|
|
575
682
|
`role="${isCheckbox ? "group" : "radiogroup"}"`
|
|
576
683
|
];
|
|
577
|
-
|
|
684
|
+
// `data-wd-multi` tells the form submit handler to collect every checked box
|
|
685
|
+
// into an array. A bound checkbox is not in a form and is not an array.
|
|
686
|
+
if (isCheckbox && !bound) groupAttrs.push(`data-wd-multi="${escapeHtml(name)}"`);
|
|
578
687
|
groupAttrs.push(`aria-label="${escapeHtml(ariaLabel || humanizeName(name))}"`);
|
|
579
688
|
if (ariaDescribedby) groupAttrs.push(`aria-describedby="${escapeHtml(ariaDescribedby)}"`);
|
|
580
689
|
const disabled = flags.includes("disabled");
|
|
581
690
|
const required = flags.includes("required");
|
|
582
691
|
const autofocus = flags.includes("autofocus");
|
|
692
|
+
const current = bound ? ctx.comp.state.get(bound) : undefined;
|
|
583
693
|
const controls = options
|
|
584
694
|
.map((opt, idx) => {
|
|
585
|
-
const a = [
|
|
695
|
+
const a = [
|
|
696
|
+
`type="${kind}"`,
|
|
697
|
+
`name="${escapeHtml(bound || name)}"`,
|
|
698
|
+
`value="${escapeHtml(opt)}"`
|
|
699
|
+
];
|
|
700
|
+
// A radio group still needs its shared `name` to be mutually exclusive in
|
|
701
|
+
// the browser, so the bound form keys it by the state key.
|
|
702
|
+
if (bound) a.push(`data-wd-bind-input="${escapeHtml(bound)}"`);
|
|
703
|
+
if (bound && (isCheckbox ? Boolean(current) : String(current ?? "") === opt))
|
|
704
|
+
a.push("checked");
|
|
586
705
|
if (disabled) a.push("disabled");
|
|
587
706
|
if (required && !isCheckbox) a.push("required");
|
|
588
707
|
if (autofocus && idx === 0) a.push("autofocus");
|
|
@@ -16,9 +16,15 @@ import { stripQuotes } from "./interpolation.js";
|
|
|
16
16
|
* Split a raw file into its frontmatter `meta` and `body`. `bodyLine` is the
|
|
17
17
|
* 0-based file line index the body starts on (the number of lines the
|
|
18
18
|
* frontmatter block consumed) so compile errors can report true file lines.
|
|
19
|
+
*
|
|
20
|
+
* `quotedKeys` names the fields the author wrote with quotes. Quotes are syntax,
|
|
21
|
+
* not content, so `meta` never carries them — but "the author quoted it" is the
|
|
22
|
+
* only signal that `sku: "007"` is the TEXT 007 and not the number 7, and the
|
|
23
|
+
* typed-collection coercion needs it. Additive: existing destructuring of
|
|
24
|
+
* `{ meta, body, bodyLine }` is unaffected.
|
|
19
25
|
* @param {string} raw Full file contents.
|
|
20
26
|
* @param {string} [file] Source path, used only for error messages.
|
|
21
|
-
* @returns {{ meta: Meta, body: string, bodyLine: number }}
|
|
27
|
+
* @returns {{ meta: Meta, body: string, bodyLine: number, quotedKeys: Set<string> }}
|
|
22
28
|
*/
|
|
23
29
|
export function parseFrontmatter(raw, file) {
|
|
24
30
|
// A file authored on Windows (or checked out with git's autocrlf) arrives
|
|
@@ -28,7 +34,7 @@ export function parseFrontmatter(raw, file) {
|
|
|
28
34
|
// in the reader keeps the direct callers (builder.js reads route frontmatter
|
|
29
35
|
// straight off disk for feeds) on the same footing. Idempotent on LF input.
|
|
30
36
|
raw = normalizeNewlines(raw);
|
|
31
|
-
if (!raw.startsWith("---\n")) return { meta: {}, body: raw, bodyLine: 0 };
|
|
37
|
+
if (!raw.startsWith("---\n")) return { meta: {}, body: raw, bodyLine: 0, quotedKeys: new Set() };
|
|
32
38
|
const end = raw.indexOf("\n---", 3);
|
|
33
39
|
if (end === -1) {
|
|
34
40
|
const where = file ? ` in ${file}` : "";
|
|
@@ -44,11 +50,16 @@ export function parseFrontmatter(raw, file) {
|
|
|
44
50
|
const bodyLine = raw.slice(0, bodyStart).split("\n").length - 1;
|
|
45
51
|
/** @type {Meta} */
|
|
46
52
|
const meta = {};
|
|
53
|
+
/** @type {Set<string>} */
|
|
54
|
+
const quotedKeys = new Set();
|
|
47
55
|
for (const line of front.split("\n")) {
|
|
48
56
|
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
49
|
-
if (match)
|
|
57
|
+
if (!match) continue;
|
|
58
|
+
const parsed = parseFrontmatterValue(match[2]);
|
|
59
|
+
meta[match[1]] = parsed.value;
|
|
60
|
+
if (parsed.quoted) quotedKeys.add(match[1]);
|
|
50
61
|
}
|
|
51
|
-
return { meta, body, bodyLine };
|
|
62
|
+
return { meta, body, bodyLine, quotedKeys };
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
/**
|
|
@@ -86,13 +97,23 @@ export function warnLikelyFrontmatter(raw, file, comp) {
|
|
|
86
97
|
// Block sequences (`- item` on following lines) are intentionally out of scope —
|
|
87
98
|
// the parser stays single-pass and line-based.
|
|
88
99
|
/**
|
|
100
|
+
* Parse one frontmatter value, reporting whether the author QUOTED it.
|
|
101
|
+
*
|
|
102
|
+
* The inline-array branch tracks `quoted` per item already (an item is a string
|
|
103
|
+
* either way, so the flag only guards whitespace trimming); the scalar branch
|
|
104
|
+
* did not, and downstream that made `sku: "007"` indistinguishable from
|
|
105
|
+
* `sku: 007`. Both are `"007"` here, but only the first is text the author
|
|
106
|
+
* pinned, so only the second is a candidate for numeric coercion.
|
|
89
107
|
* @param {string} raw
|
|
90
|
-
* @returns {FrontmatterValue}
|
|
108
|
+
* @returns {{ value: FrontmatterValue, quoted: boolean }}
|
|
91
109
|
*/
|
|
92
110
|
function parseFrontmatterValue(raw) {
|
|
93
111
|
const value = raw.trim();
|
|
94
|
-
if (value.startsWith("[") && value.endsWith("]"))
|
|
95
|
-
|
|
112
|
+
if (value.startsWith("[") && value.endsWith("]"))
|
|
113
|
+
return { value: parseInlineArray(value), quoted: false };
|
|
114
|
+
const quote = value[0];
|
|
115
|
+
const quoted = value.length >= 2 && (quote === '"' || quote === "'") && value.endsWith(quote);
|
|
116
|
+
return { value: stripQuotes(value), quoted };
|
|
96
117
|
}
|
|
97
118
|
|
|
98
119
|
/**
|
package/src/compiler/includes.js
CHANGED
|
@@ -180,14 +180,23 @@ export function resolveInclude(spec, fromFile, context, allowAny, loc, reader) {
|
|
|
180
180
|
}
|
|
181
181
|
for (const candidate of candidates) {
|
|
182
182
|
const resolved = path.resolve(candidate);
|
|
183
|
-
|
|
183
|
+
const refuse = () => {
|
|
184
184
|
throw wdError(`Include "${spec}" from ${loc} resolves outside site/pages or site/_`, {
|
|
185
185
|
code: "WD601",
|
|
186
186
|
file: fromFile
|
|
187
187
|
});
|
|
188
|
-
}
|
|
188
|
+
};
|
|
189
|
+
// Lexical containment first: it is the only check available for a path that
|
|
190
|
+
// does not exist yet, and it is what rejects `../` traversal and a
|
|
191
|
+
// sibling-prefix directory (`site/pages-secret/`).
|
|
192
|
+
if (!isAllowedInclude(resolved, context)) refuse();
|
|
189
193
|
if (!reader.exists(resolved)) continue;
|
|
190
194
|
if (!allowAny && !pageIncludeExtensions.includes(path.extname(resolved))) continue;
|
|
195
|
+
// CANONICAL containment second: the lexical check reads the path as WRITTEN,
|
|
196
|
+
// so a symlink sitting inside `site/pages` and pointing anywhere on the disk
|
|
197
|
+
// passed it and leaked the target's bytes into the built page. Only reachable
|
|
198
|
+
// once the file exists, which is why it cannot replace the lexical check.
|
|
199
|
+
if (!isAllowedInclude(resolved, context, reader)) refuse();
|
|
191
200
|
return resolved;
|
|
192
201
|
}
|
|
193
202
|
throw wdError(
|
|
@@ -197,13 +206,42 @@ export function resolveInclude(spec, fromFile, context, allowAny, loc, reader) {
|
|
|
197
206
|
}
|
|
198
207
|
|
|
199
208
|
/**
|
|
200
|
-
*
|
|
209
|
+
* Whether an absolute path is inside the `site/pages` / `site/_` sandbox.
|
|
210
|
+
*
|
|
211
|
+
* Two modes, both used by {@link resolveInclude}. Without a `reader` the check is
|
|
212
|
+
* LEXICAL — the path exactly as written — which is what rejects `../` traversal
|
|
213
|
+
* and a sibling directory that merely shares the root's prefix
|
|
214
|
+
* (`site/pages-secret/` is not inside `site/pages/`, hence the `path.sep`
|
|
215
|
+
* boundary). With a `reader` it re-runs against the CANONICAL paths, closing the
|
|
216
|
+
* symlink hole: a link inside `site/pages` pointing at `../../secret.md` is
|
|
217
|
+
* lexically fine and still escapes the sandbox. Both sides are canonicalized,
|
|
218
|
+
* because a project root can itself live under a symlinked parent (macOS
|
|
219
|
+
* `/tmp` → `/private/tmp`), where realpathing only the file would reject every
|
|
220
|
+
* legitimate include.
|
|
221
|
+
* @param {string} file Absolute path to the include target.
|
|
201
222
|
* @param {Paths} context
|
|
223
|
+
* @param {import("./reader.js").Reader} [reader] When given, also check the
|
|
224
|
+
* canonical (symlink-resolved) path. Requires `file` to exist.
|
|
202
225
|
* @returns {boolean}
|
|
203
226
|
*/
|
|
204
|
-
export function isAllowedInclude(file, context) {
|
|
227
|
+
export function isAllowedInclude(file, context, reader) {
|
|
205
228
|
const roots = [context.routesRoot, context.shelfRoot].map((root) => path.resolve(root));
|
|
206
|
-
|
|
229
|
+
/** @param {string} target @param {string[]} within */
|
|
230
|
+
const contains = (target, within) =>
|
|
231
|
+
within.some((root) => target === root || target.startsWith(`${root}${path.sep}`));
|
|
232
|
+
if (!contains(path.resolve(file), roots)) return false;
|
|
233
|
+
if (!reader) return true;
|
|
234
|
+
// A root that does not exist (a project with no `site/_`) has no canonical
|
|
235
|
+
// form; keep it as written rather than failing the whole check.
|
|
236
|
+
/** @param {string} target */
|
|
237
|
+
const canonical = (target) => {
|
|
238
|
+
try {
|
|
239
|
+
return reader.realpath(target);
|
|
240
|
+
} catch {
|
|
241
|
+
return target;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
return contains(canonical(path.resolve(file)), roots.map(canonical));
|
|
207
245
|
}
|
|
208
246
|
|
|
209
247
|
// ---------------------------------------------------------------------------
|
|
@@ -231,7 +269,9 @@ export function scanMarkdownHints(body, file, comp) {
|
|
|
231
269
|
continue;
|
|
232
270
|
}
|
|
233
271
|
const hit = line.match(
|
|
234
|
-
|
|
272
|
+
// `:note`/`:sprint` are gone from the `.wd` directive set, so naming them
|
|
273
|
+
// here would tell a `.md` author to rename a file to activate nothing.
|
|
274
|
+
/^(@include|@loop|@repeat|:state|:button|:if|:for|:try|:::)(\s|$)/
|
|
235
275
|
);
|
|
236
276
|
if (hit) {
|
|
237
277
|
comp.warnings.push(
|
|
@@ -22,7 +22,14 @@ export function lookupVar(scope, name) {
|
|
|
22
22
|
/** @type {Scope | null} */
|
|
23
23
|
let current = scope;
|
|
24
24
|
for (; current; current = current.parent) {
|
|
25
|
-
|
|
25
|
+
// `Object.hasOwn`, never `in`: a scope frame is a plain object, so `in` also
|
|
26
|
+
// answers true for every Object.prototype member. With it, `{ toString }`
|
|
27
|
+
// resolved to the inherited function and rendered
|
|
28
|
+
// "function toString() { [native code] }" into the page, and
|
|
29
|
+
// `@include /p.wd with v={ constructor }` leaked the Object constructor into
|
|
30
|
+
// the value pipeline. Own keys only — the scope holds exactly what the
|
|
31
|
+
// author put there.
|
|
32
|
+
if (Object.hasOwn(current.vars, name)) return { found: true, value: current.vars[name] };
|
|
26
33
|
}
|
|
27
34
|
return { found: false };
|
|
28
35
|
}
|
|
@@ -112,6 +119,26 @@ export function interpolateLeaf(value, expr, ctx) {
|
|
|
112
119
|
return String(value);
|
|
113
120
|
}
|
|
114
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Render the INITIAL value of a reactive binding (`data-wd-bind` /
|
|
124
|
+
* `data-wd-each`) as text.
|
|
125
|
+
*
|
|
126
|
+
* Identical to {@link interpolateLeaf} except for arrays: the runtime paints a
|
|
127
|
+
* bind by assigning to `textContent`, which coerces `["a", "b"]` with `String()`
|
|
128
|
+
* to `"a,b"`, and the loop row-template fill already agrees (it escapes through
|
|
129
|
+
* `String()` too). `interpolateLeaf`'s `", "` join is the STATIC contract — the
|
|
130
|
+
* documented `{ meta.tags }` behavior for a value nothing will ever repaint — so
|
|
131
|
+
* the two cannot share one rule. Painting the static form here made the first
|
|
132
|
+
* reactive render silently rewrite `"a, b"` to `"a,b"`.
|
|
133
|
+
* @param {unknown} value
|
|
134
|
+
* @param {string} expr
|
|
135
|
+
* @param {Ctx} ctx
|
|
136
|
+
* @returns {string}
|
|
137
|
+
*/
|
|
138
|
+
export function interpolateBound(value, expr, ctx) {
|
|
139
|
+
return Array.isArray(value) ? String(value) : interpolateLeaf(value, expr, ctx);
|
|
140
|
+
}
|
|
141
|
+
|
|
115
142
|
// ---------------------------------------------------------------------------
|
|
116
143
|
// Literals
|
|
117
144
|
// ---------------------------------------------------------------------------
|
|
@@ -201,6 +228,34 @@ export function humanizeName(name) {
|
|
|
201
228
|
return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
|
|
202
229
|
}
|
|
203
230
|
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// A BOUND href/src CARRIES A VALUE THE COMPILER NEVER SEES.
|
|
233
|
+
//
|
|
234
|
+
// `validateFetchUrl` and markdown-it's `validateLink` both vet a URL the AUTHOR
|
|
235
|
+
// wrote. A reactive attribute binding (`[go]({ url })`) is different: the
|
|
236
|
+
// compiler only sees the seed, and every later value arrives at runtime from
|
|
237
|
+
// state, a form field, or a fetched payload. So the scheme check has to run
|
|
238
|
+
// where the value lands, and it has to run in BOTH places — here for the
|
|
239
|
+
// build-time paint, and again in `src/runtime.js` for every repaint.
|
|
240
|
+
//
|
|
241
|
+
// Control characters are stripped before the test because browsers strip tab,
|
|
242
|
+
// newline and carriage return out of a URL attribute before resolving it, so
|
|
243
|
+
// `java\tscript:alert(1)` is a live `javascript:` URL to the browser and must be
|
|
244
|
+
// one to this guard too. Whitespace is stripped along with them, which only
|
|
245
|
+
// makes the test stricter. `tests/attr-binding.test.js` runs the same table
|
|
246
|
+
// through the compiler and the runtime so the two cannot drift.
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* True for a value that must never be applied to a bound `href`/`src`.
|
|
251
|
+
* @param {unknown} value
|
|
252
|
+
* @returns {boolean}
|
|
253
|
+
*/
|
|
254
|
+
export function unsafeUrlValue(value) {
|
|
255
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — browsers strip these out of a URL, so the guard must too.
|
|
256
|
+
return /^(javascript|data|vbscript):/i.test(String(value).replace(/[\u0000-\u0020\u007F]+/g, ""));
|
|
257
|
+
}
|
|
258
|
+
|
|
204
259
|
/**
|
|
205
260
|
* Escape a value for safe inclusion in HTML text and attribute contexts.
|
|
206
261
|
* @param {unknown} value
|