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