@zvndev/darkmown 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +54 -3
- package/docs/cli.md +46 -16
- package/docs/spec-alignment.md +9 -3
- package/package.json +1 -1
- 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 +21 -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/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 +14 -0
- package/src/compiler/context.js +12 -1
- package/src/compiler/directives.js +123 -0
- 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
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();
|
package/src/scaffold.js
CHANGED
|
@@ -1,24 +1,60 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const templatesDir = path.join(moduleDir, "templates");
|
|
3
7
|
|
|
4
8
|
const darkmownVersion = JSON.parse(
|
|
5
9
|
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
6
10
|
).version;
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
|
-
*
|
|
13
|
+
* List the available scaffold templates (directory names under `src/templates`).
|
|
14
|
+
* @returns {string[]}
|
|
15
|
+
*/
|
|
16
|
+
export function availableTemplates() {
|
|
17
|
+
if (!fs.existsSync(templatesDir)) return [];
|
|
18
|
+
return fs
|
|
19
|
+
.readdirSync(templatesDir, { withFileTypes: true })
|
|
20
|
+
.filter((entry) => entry.isDirectory())
|
|
21
|
+
.map((entry) => entry.name)
|
|
22
|
+
.sort();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Scaffold a new Darkmown project into `root` from a template, creating files
|
|
27
|
+
* that don't already exist (never overwriting). `package.json` is generated (so
|
|
28
|
+
* the name tracks the directory and the `@zvndev/darkmown` dep pins the installed
|
|
29
|
+
* version); every other file is copied verbatim from the chosen template.
|
|
10
30
|
* @param {string} root Absolute path to the target project directory.
|
|
11
|
-
* @
|
|
31
|
+
* @param {{ template?: string }} [options] `template` defaults to `"starter"`.
|
|
32
|
+
* @returns {{ root: string, template: string }}
|
|
12
33
|
*/
|
|
13
|
-
export function initProject(root) {
|
|
34
|
+
export function initProject(root, options = {}) {
|
|
35
|
+
const template = options.template || "starter";
|
|
36
|
+
const templateDir = path.join(templatesDir, template);
|
|
37
|
+
if (!fs.existsSync(templateDir) || !fs.statSync(templateDir).isDirectory()) {
|
|
38
|
+
const list = availableTemplates().join(", ");
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Unknown template "${template}". Available templates: ${list || "(none)"}. ` +
|
|
41
|
+
"Use: darkmown init [dir] --template <name>"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
14
45
|
fs.mkdirSync(root, { recursive: true });
|
|
46
|
+
|
|
47
|
+
// package.json is generated, not copied: the name follows the target directory,
|
|
48
|
+
// the dep pins the installed Darkmown version, and `type: "module"` makes a
|
|
49
|
+
// project `api/*.js` import as ESM in `darkmown dev` and on every host.
|
|
15
50
|
writeNew(
|
|
16
51
|
root,
|
|
17
52
|
"package.json",
|
|
18
|
-
JSON.stringify(
|
|
53
|
+
`${JSON.stringify(
|
|
19
54
|
{
|
|
20
55
|
name: packageNameFromRoot(root),
|
|
21
56
|
private: true,
|
|
57
|
+
type: "module",
|
|
22
58
|
scripts: {
|
|
23
59
|
dev: "darkmown dev",
|
|
24
60
|
build: "darkmown build",
|
|
@@ -30,161 +66,57 @@ export function initProject(root) {
|
|
|
30
66
|
},
|
|
31
67
|
null,
|
|
32
68
|
2
|
|
33
|
-
)
|
|
34
|
-
);
|
|
35
|
-
writeNew(
|
|
36
|
-
root,
|
|
37
|
-
"site/pages/index.wd",
|
|
38
|
-
[
|
|
39
|
-
"---",
|
|
40
|
-
"title: My Darkmown site",
|
|
41
|
-
"---",
|
|
42
|
-
"",
|
|
43
|
-
"@include /nav.wd",
|
|
44
|
-
"",
|
|
45
|
-
"<main>",
|
|
46
|
-
"",
|
|
47
|
-
"# My Darkmown site",
|
|
48
|
-
"",
|
|
49
|
-
"Plain Markdown works. Rename to `.wd` when you want directives.",
|
|
50
|
-
"",
|
|
51
|
-
":state count = 0",
|
|
52
|
-
"",
|
|
53
|
-
"Count: { count }",
|
|
54
|
-
"",
|
|
55
|
-
':button "Increment" -> count++',
|
|
56
|
-
"",
|
|
57
|
-
':state todos = ["Write a page"]',
|
|
58
|
-
"",
|
|
59
|
-
"@loop todos into todo",
|
|
60
|
-
"- { todo }",
|
|
61
|
-
"@endloop",
|
|
62
|
-
"",
|
|
63
|
-
':button "Add" -> todos += "Add a loop"',
|
|
64
|
-
"",
|
|
65
|
-
"</main>"
|
|
66
|
-
].join("\n")
|
|
67
|
-
);
|
|
68
|
-
writeNew(
|
|
69
|
-
root,
|
|
70
|
-
"site/pages/index.skin",
|
|
71
|
-
[
|
|
72
|
-
"tokens",
|
|
73
|
-
" ink #172026",
|
|
74
|
-
" paper #f7f2e8",
|
|
75
|
-
" accent #0f8b8d",
|
|
76
|
-
"",
|
|
77
|
-
"page",
|
|
78
|
-
" margin 0",
|
|
79
|
-
" font ui-sans-serif, system-ui, sans-serif",
|
|
80
|
-
" color $ink",
|
|
81
|
-
" bg $paper",
|
|
82
|
-
"",
|
|
83
|
-
"main",
|
|
84
|
-
" max-width 760px",
|
|
85
|
-
" margin 0 auto",
|
|
86
|
-
" padding 3rem 1.5rem",
|
|
87
|
-
"",
|
|
88
|
-
"nav",
|
|
89
|
-
" display flex",
|
|
90
|
-
" align-items center",
|
|
91
|
-
" justify-content space-between",
|
|
92
|
-
" gap 1rem",
|
|
93
|
-
" max-width 760px",
|
|
94
|
-
" margin 0 auto",
|
|
95
|
-
" padding 1.25rem 1.5rem",
|
|
96
|
-
"",
|
|
97
|
-
"nav strong",
|
|
98
|
-
" font-size 1.1rem",
|
|
99
|
-
"",
|
|
100
|
-
"nav .links",
|
|
101
|
-
" display flex",
|
|
102
|
-
" gap 1.25rem",
|
|
103
|
-
"",
|
|
104
|
-
"nav a",
|
|
105
|
-
" color $ink",
|
|
106
|
-
" text-decoration none",
|
|
107
|
-
" opacity .8",
|
|
108
|
-
"",
|
|
109
|
-
"nav a:hover",
|
|
110
|
-
" opacity 1",
|
|
111
|
-
" color $accent",
|
|
112
|
-
"",
|
|
113
|
-
"button",
|
|
114
|
-
" bg $accent",
|
|
115
|
-
" color white",
|
|
116
|
-
" border 0",
|
|
117
|
-
" padding .75rem 1rem",
|
|
118
|
-
" radius 8px"
|
|
119
|
-
].join("\n")
|
|
120
|
-
);
|
|
121
|
-
writeNew(
|
|
122
|
-
root,
|
|
123
|
-
"site/_/nav.wd",
|
|
124
|
-
[
|
|
125
|
-
"<nav>",
|
|
126
|
-
" <strong>My site</strong>",
|
|
127
|
-
' <span class="links">',
|
|
128
|
-
' <a href="/">Home</a>',
|
|
129
|
-
' <a href="/about/">About</a>',
|
|
130
|
-
" </span>",
|
|
131
|
-
"</nav>"
|
|
132
|
-
].join("\n")
|
|
69
|
+
)}\n`
|
|
133
70
|
);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
"title: About",
|
|
140
|
-
"---",
|
|
141
|
-
"",
|
|
142
|
-
"# About",
|
|
143
|
-
"",
|
|
144
|
-
"This page is plain Markdown (`.md`) — strict CommonMark, zero framework JavaScript.",
|
|
145
|
-
"Rename it to `.wd` when you want directives like state, loops, or forms.",
|
|
146
|
-
"",
|
|
147
|
-
"[Back home](/)"
|
|
148
|
-
].join("\n")
|
|
149
|
-
);
|
|
150
|
-
writeNew(
|
|
151
|
-
root,
|
|
152
|
-
"README.md",
|
|
153
|
-
[
|
|
154
|
-
"# My Darkmown site",
|
|
155
|
-
"",
|
|
156
|
-
"Run `npm install` and `npm run dev` to start the live compiler.",
|
|
157
|
-
"",
|
|
158
|
-
"## Commands",
|
|
159
|
-
"",
|
|
160
|
-
"- `npm run dev` — live compiler on http://localhost:5173 with hot reload.",
|
|
161
|
-
"- `npm run build` — compile the site to `dist/`.",
|
|
162
|
-
"- `npm run preview` — serve the built `dist/` locally.",
|
|
163
|
-
"",
|
|
164
|
-
"## Layout",
|
|
165
|
-
"",
|
|
166
|
-
"- Pages live in `site/pages`.",
|
|
167
|
-
"- Shared includes live in `site/_`.",
|
|
168
|
-
"- Hidden route files start with `.` or `-`.",
|
|
169
|
-
"- Colocated `index.skin` and `index.js` attach automatically.",
|
|
170
|
-
"- Any other file under `site/pages` (images, fonts, …) copies to `dist/` at its own path."
|
|
171
|
-
].join("\n")
|
|
172
|
-
);
|
|
173
|
-
return { root };
|
|
71
|
+
|
|
72
|
+
for (const rel of walk(templateDir)) {
|
|
73
|
+
copyNew(path.join(templateDir, rel), path.join(root, rel));
|
|
74
|
+
}
|
|
75
|
+
return { root, template };
|
|
174
76
|
}
|
|
175
77
|
|
|
176
78
|
/**
|
|
177
|
-
* Write a file relative to `root` only if it does not already exist.
|
|
79
|
+
* Write a file relative to `root` only if it does not already exist. Content is
|
|
80
|
+
* written verbatim (callers include any trailing newline).
|
|
178
81
|
* @param {string} root Project root directory.
|
|
179
82
|
* @param {string} file Path relative to `root`.
|
|
180
|
-
* @param {string} content File contents
|
|
83
|
+
* @param {string} content File contents.
|
|
181
84
|
* @returns {void}
|
|
182
85
|
*/
|
|
183
86
|
function writeNew(root, file, content) {
|
|
184
87
|
const target = path.join(root, file);
|
|
185
88
|
if (fs.existsSync(target)) return;
|
|
186
89
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
187
|
-
fs.writeFileSync(target,
|
|
90
|
+
fs.writeFileSync(target, content);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Copy a template file to `dest`, preserving bytes, only if `dest` is absent.
|
|
95
|
+
* @param {string} source
|
|
96
|
+
* @param {string} dest
|
|
97
|
+
* @returns {void}
|
|
98
|
+
*/
|
|
99
|
+
function copyNew(source, dest) {
|
|
100
|
+
if (fs.existsSync(dest)) return;
|
|
101
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
102
|
+
fs.copyFileSync(source, dest);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Relative paths of every file under `dir` (POSIX-separated), recursively.
|
|
107
|
+
* @param {string} dir
|
|
108
|
+
* @param {string} [base]
|
|
109
|
+
* @returns {string[]}
|
|
110
|
+
*/
|
|
111
|
+
function walk(dir, base = dir) {
|
|
112
|
+
/** @type {string[]} */
|
|
113
|
+
const files = [];
|
|
114
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
115
|
+
const abs = path.join(dir, entry.name);
|
|
116
|
+
if (entry.isDirectory()) files.push(...walk(abs, base));
|
|
117
|
+
else files.push(path.relative(base, abs).replaceAll(path.sep, "/"));
|
|
118
|
+
}
|
|
119
|
+
return files;
|
|
188
120
|
}
|
|
189
121
|
|
|
190
122
|
/**
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Blog — a Darkmown template
|
|
2
|
+
|
|
3
|
+
A markdown-native blog. Posts are plain `.md` files in `site/pages/posts/`; the
|
|
4
|
+
home page loops `site/_/posts.json` to list them. Static pages, zero JavaScript.
|
|
5
|
+
|
|
6
|
+
## Add a post
|
|
7
|
+
|
|
8
|
+
1. Create `site/pages/posts/<slug>.md` with a `title` in frontmatter.
|
|
9
|
+
2. Add an entry to `site/_/posts.json` (`slug`, `url`, `title`, `date`, `excerpt`).
|
|
10
|
+
|
|
11
|
+
## Commands
|
|
12
|
+
|
|
13
|
+
- `npm run dev` — live compiler on http://localhost:5173.
|
|
14
|
+
- `npm run build` — compile to `dist/`.
|
|
15
|
+
- `npm run preview` — serve the built `dist/` locally.
|
|
16
|
+
|
|
17
|
+
## Deploy
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npx darkmown deploy vercel # or: npx darkmown deploy cloudflare
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
[](https://vercel.com/new/clone?repository-url=YOUR_REPO_URL)
|
|
24
|
+
|
|
25
|
+
[](https://deploy.workers.cloudflare.com/?url=YOUR_REPO_URL)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"slug": "hello-darkmown",
|
|
4
|
+
"url": "/posts/hello-darkmown/",
|
|
5
|
+
"title": "Hello, Darkmown",
|
|
6
|
+
"date": "2026-06-15",
|
|
7
|
+
"excerpt": "Why I rewrote my blog as plain Markdown files — and shipped zero framework JavaScript."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"slug": "markdown-native",
|
|
11
|
+
"url": "/posts/markdown-native/",
|
|
12
|
+
"title": "Markdown-native, by default",
|
|
13
|
+
"date": "2026-06-01",
|
|
14
|
+
"excerpt": "Every post is a .md file. The index is one .wd loop over this manifest. That's the whole site."
|
|
15
|
+
}
|
|
16
|
+
]
|