@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +54 -3
  3. package/docs/cli.md +46 -16
  4. package/docs/spec-alignment.md +9 -3
  5. package/package.json +1 -1
  6. package/site/_/roadmap.json +3 -1
  7. package/site/pages/app.wd +1 -1
  8. package/site/pages/data.wd +2 -2
  9. package/site/pages/docs/index.wd +21 -0
  10. package/site/pages/folio/cart.wd +2 -2
  11. package/site/pages/forge/index.wd +1 -1
  12. package/site/pages/interactions/index.skin +113 -0
  13. package/site/pages/interactions/index.wd +86 -0
  14. package/src/api-runner.js +219 -0
  15. package/src/behaviors/carousel.js +153 -0
  16. package/src/behaviors/sortable.js +175 -0
  17. package/src/builder.js +199 -4
  18. package/src/cli.js +79 -54
  19. package/src/compiler/body.js +14 -0
  20. package/src/compiler/context.js +12 -1
  21. package/src/compiler/directives.js +123 -0
  22. package/src/compiler/loops.js +57 -8
  23. package/src/compiler/page.js +9 -3
  24. package/src/deploy.js +174 -0
  25. package/src/headers.js +4 -0
  26. package/src/runtime.js +3 -1
  27. package/src/scaffold.js +79 -147
  28. package/src/templates/blog/README.md +25 -0
  29. package/src/templates/blog/site/_/nav.wd +6 -0
  30. package/src/templates/blog/site/_/posts.json +16 -0
  31. package/src/templates/blog/site/pages/index.skin +51 -0
  32. package/src/templates/blog/site/pages/index.wd +24 -0
  33. package/src/templates/blog/site/pages/posts/hello-darkmown.md +16 -0
  34. package/src/templates/blog/site/pages/posts/markdown-native.md +20 -0
  35. package/src/templates/dashboard/README.md +25 -0
  36. package/src/templates/dashboard/api/metrics.js +15 -0
  37. package/src/templates/dashboard/site/_/nav.wd +6 -0
  38. package/src/templates/dashboard/site/pages/index.skin +74 -0
  39. package/src/templates/dashboard/site/pages/index.wd +75 -0
  40. package/src/templates/landing/README.md +25 -0
  41. package/src/templates/landing/site/_/nav.wd +6 -0
  42. package/src/templates/landing/site/pages/index.skin +140 -0
  43. package/src/templates/landing/site/pages/index.wd +54 -0
  44. package/src/templates/starter/README.md +32 -0
  45. package/src/templates/starter/site/_/nav.wd +7 -0
  46. package/src/templates/starter/site/pages/about.md +10 -0
  47. package/src/templates/starter/site/pages/index.skin +47 -0
  48. package/src/templates/starter/site/pages/index.wd +27 -0
  49. package/src/templates/store/README.md +28 -0
  50. package/src/templates/store/api/checkout.js +18 -0
  51. package/src/templates/store/site/_/nav.wd +6 -0
  52. package/src/templates/store/site/_/products.json +8 -0
  53. package/src/templates/store/site/pages/index.skin +100 -0
  54. package/src/templates/store/site/pages/index.wd +83 -0
@@ -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 = "Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N]";
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
- : { where: null, sort: null, reverse: false, offset: null, limit: null, refsState: false };
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
 
@@ -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
- const scriptSrcs = compiled.assets.runtime
65
- ? ["/__wd/runtime.js", ...compiled.assets.scripts]
66
- : [...compiled.assets.scripts];
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
- state[input.getAttribute("data-wd-bind-input") || ""] = input.value;
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
- * Scaffold a new Darkmown project into `root`, creating files that don't exist.
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
- * @returns {{ root: string }}
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
- writeNew(
135
- root,
136
- "site/pages/about.md",
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 (a trailing newline is added).
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, `${content}\n`);
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
+ [![Deploy to Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=YOUR_REPO_URL)
24
+ &nbsp;
25
+ [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=YOUR_REPO_URL)
@@ -0,0 +1,6 @@
1
+ <nav>
2
+ <strong>My Blog</strong>
3
+ <span class="links">
4
+ <a href="/">Posts</a>
5
+ </span>
6
+ </nav>
@@ -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
+ ]