@zvndev/darkmown 0.18.0 → 0.20.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/src/dev.js CHANGED
@@ -8,7 +8,9 @@ export const devEventsPath = "/__wd/dev-events";
8
8
  */
9
9
  export function injectDevClient(html) {
10
10
  const script = `<script type="module" src="${devClientPath}"></script>`;
11
- return html.includes("</body>") ? html.replace("</body>", `${script}\n</body>`) : `${html}\n${script}`;
11
+ return html.includes("</body>")
12
+ ? html.replace("</body>", `${script}\n</body>`)
13
+ : `${html}\n${script}`;
12
14
  }
13
15
 
14
16
  /**
package/src/headers.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Security headers Darkmown emits on every delivery surface — the local
3
+ * `darkmown serve`/preview server (`src/statics.js`), Cloudflare Pages
4
+ * (`dist/_headers`, written by `src/builder.js`), and Vercel (`vercel.json`).
5
+ *
6
+ * `statics.js` and the generated `_headers` derive from the values here, so they
7
+ * never drift. `vercel.json` is the exception: Vercel reads it BEFORE the build
8
+ * runs, so its CSP strings and static-route list are hand-maintained copies, not
9
+ * build output. The `vercel-csp-routes` test cross-checks vercel.json against the
10
+ * built route manifest so a new static route can't silently miss the strict CSP.
11
+ *
12
+ * The CSP is derived from what the framework's own output actually emits
13
+ * (verify against built `dist/` HTML):
14
+ * - External script `/__wd/runtime.js` (same-origin) on reactive pages.
15
+ * - Inline `<script type="application/json" data-wd-state>` (state seed) and
16
+ * inline `<script type="speculationrules">` (prerender) — JSON/speculation
17
+ * blocks can't be nonce'd cleanly, so `script-src 'unsafe-inline'`.
18
+ * - The reactive runtime compiles validated expressions via `new Function`,
19
+ * so reactive pages additionally need `script-src 'unsafe-eval'`.
20
+ * - Inline `<style>` for view transitions — `style-src 'unsafe-inline'`.
21
+ * - `data:` favicon and remote images — `img-src 'self' data: https:`.
22
+ *
23
+ * Static pages (zero framework JS) still emit inline `<style>` and an inline
24
+ * `<script type="speculationrules">`, so they keep `'unsafe-inline'`, but they
25
+ * never call `new Function`, so their CSP drops `'unsafe-eval'`.
26
+ */
27
+
28
+ /**
29
+ * `connect-src` for the demo is `'self'`: the framework's own pages only
30
+ * `:fetch` same-origin endpoints. Apps whose `:fetch` targets a remote host
31
+ * must widen this (e.g. to `connect-src 'self' https://api.example.com` or
32
+ * `connect-src *`) by editing their `vercel.json` / `_headers` / serve config.
33
+ * @type {string}
34
+ */
35
+ const CONNECT_SRC = "connect-src 'self'";
36
+
37
+ /**
38
+ * Directives shared by both the static and reactive CSP. Order is cosmetic.
39
+ * @type {string[]}
40
+ */
41
+ const COMMON_CSP_DIRECTIVES = [
42
+ "default-src 'self'",
43
+ "style-src 'self' 'unsafe-inline'",
44
+ "img-src 'self' data: https:",
45
+ "font-src 'self'",
46
+ CONNECT_SRC,
47
+ "object-src 'none'",
48
+ "base-uri 'self'",
49
+ "frame-ancestors 'self'"
50
+ ];
51
+
52
+ /**
53
+ * Relaxed CSP for reactive pages: `new Function` needs `'unsafe-eval'`, the
54
+ * inline state/speculationrules JSON needs `'unsafe-inline'`.
55
+ * @type {string}
56
+ */
57
+ export const REACTIVE_CSP = [
58
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
59
+ ...COMMON_CSP_DIRECTIVES
60
+ ].join("; ");
61
+
62
+ /**
63
+ * Stricter CSP for static pages: no runtime means no `new Function`, so
64
+ * `'unsafe-eval'` is dropped. Inline `<style>`/`<script type=speculationrules>`
65
+ * keep `'unsafe-inline'` on script/style.
66
+ * @type {string}
67
+ */
68
+ export const STATIC_CSP = ["script-src 'self' 'unsafe-inline'", ...COMMON_CSP_DIRECTIVES].join(
69
+ "; "
70
+ );
71
+
72
+ /**
73
+ * Baseline security headers applied to every HTML response, independent of the
74
+ * CSP. CSP is added per-route (static vs reactive) by the helpers below.
75
+ * @type {Record<string, string>}
76
+ */
77
+ export const BASE_SECURITY_HEADERS = {
78
+ "X-Content-Type-Options": "nosniff",
79
+ "Referrer-Policy": "strict-origin-when-cross-origin",
80
+ "X-Frame-Options": "SAMEORIGIN"
81
+ };
82
+
83
+ /**
84
+ * The full security-header set for one response, including the CSP variant.
85
+ * @param {boolean} reactive Whether the route ships the reactive runtime.
86
+ * @returns {Record<string, string>}
87
+ */
88
+ export function securityHeaders(reactive) {
89
+ return {
90
+ ...BASE_SECURITY_HEADERS,
91
+ "Content-Security-Policy": reactive ? REACTIVE_CSP : STATIC_CSP
92
+ };
93
+ }
package/src/index.js ADDED
@@ -0,0 +1,22 @@
1
+ // Public API barrel for @zvndev/darkmown.
2
+ // Mirrors the type surface declared in `types/index.d.ts`. Importing the
3
+ // package (`import { buildSite } from "@zvndev/darkmown"`) resolves here via the
4
+ // `exports` map; individual modules stay importable by their own path too.
5
+
6
+ export { buildSite } from "./builder.js";
7
+
8
+ export {
9
+ compileDocument,
10
+ compilePage,
11
+ escapeHtml,
12
+ loopKeyOf,
13
+ parseFrontmatter
14
+ } from "./compiler.js";
15
+
16
+ export { createPaths, isHiddenName, pageExtensions } from "./config.js";
17
+ export { devClientScript, injectDevClient } from "./dev.js";
18
+ export { discoverRoutes, outputPathForRoute, routeFromFile } from "./router.js";
19
+
20
+ export { initProject } from "./scaffold.js";
21
+ export { compileSkin } from "./skin.js";
22
+ export { contentType, resolvePublicFile, serve } from "./statics.js";
package/src/router.js CHANGED
@@ -43,7 +43,9 @@ export function discoverRoutes(routesRoot) {
43
43
  const seen = new Map();
44
44
  for (const route of routes) {
45
45
  if (seen.has(route.route)) {
46
- throw new Error(`Duplicate route "${route.route}" from ${seen.get(route.route)} and ${route.file}`);
46
+ throw new Error(
47
+ `Duplicate route "${route.route}" from ${seen.get(route.route)} and ${route.file}`
48
+ );
47
49
  }
48
50
  seen.set(route.route, route.file);
49
51
  }
package/src/runtime.js CHANGED
@@ -246,9 +246,15 @@ function loopKeyOf(item, counts) {
246
246
  * @returns {void}
247
247
  */
248
248
  function fillItem(node, item, meta = {}) {
249
+ // Nested item-relative loops (data-wd-loop-item) own their descendants' binds;
250
+ // when present, `skip` excludes a node living inside one so the outer row's
251
+ // text/meta/each-if/class passes never overwrite an inner row's values.
252
+ const inners = node.querySelectorAll("[data-wd-loop-item]");
253
+ const skip = inners.length ? (/** @type {Element} */ el) => el.closest("[data-wd-loop-item]") : () => false;
249
254
  // querySelectorAll skips <template> content, so this sees only the outermost
250
255
  // regions; recursing into each injected branch fills the rest.
251
256
  for (const region of node.querySelectorAll("[data-wd-each-if]")) {
257
+ if (skip(region)) continue;
252
258
  const expr = region.getAttribute("data-wd-if-expr");
253
259
  const m = region.getAttribute("data-wd-meta");
254
260
  const value = expr ? evalPredicate(expr, item) : (m ? meta[m] : getPath(item, region.getAttribute("data-wd-path")));
@@ -262,13 +268,17 @@ function fillItem(node, item, meta = {}) {
262
268
  ? [node, ...node.querySelectorAll("[data-wd-each]")]
263
269
  : [...node.querySelectorAll("[data-wd-each]")];
264
270
  for (const target of targets) {
271
+ if (skip(target)) continue;
265
272
  target.textContent = getPath(item, target.getAttribute("data-wd-path")) ?? "";
266
273
  }
267
274
  for (const marker of node.querySelectorAll("[data-wd-each-meta]")) {
275
+ if (skip(marker)) continue;
268
276
  marker.textContent = meta[marker.getAttribute("data-wd-each-meta") || ""] ?? "";
269
277
  }
270
278
  const classNodes = node.matches("[data-wd-each-class]") ? [node, ...node.querySelectorAll("[data-wd-each-class]")] : [...node.querySelectorAll("[data-wd-each-class]")];
271
- for (const el of classNodes) classToggle(el, el.getAttribute("data-wd-each-class"), item);
279
+ for (const el of classNodes) { if (!skip(el)) classToggle(el, el.getAttribute("data-wd-each-class"), item); }
280
+ // Reconcile nested loops last, against the now-settled outer row.
281
+ for (const region of inners) reconcile(region, getPath(item, region.getAttribute("data-wd-loop-item")));
272
282
  }
273
283
 
274
284
  /**
@@ -335,6 +345,75 @@ function renderIf(node) {
335
345
  for (const nested of output.querySelectorAll("[data-wd-if]")) renderIf(nested);
336
346
  }
337
347
 
348
+ /**
349
+ * Reconcile one loop region (top-level or nested) from a resolved row source:
350
+ * filter (`where`) → pipeline (sort/reverse/offset/limit) → @empty → keyed
351
+ * reconcile. Each surviving row is `fillItem`-ed, which itself recurses into any
352
+ * nested item-relative loops. Shared by the global pass and `fillItem`.
353
+ * @param {Element} region
354
+ * @param {any} rows Source list (state-resolved globally; item-relative inside fillItem).
355
+ * @returns {void}
356
+ */
357
+ function reconcile(region, rows) {
358
+ const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-template]"));
359
+ const out = region.querySelector("[data-wd-loop-out]");
360
+ if (!template || !out) return;
361
+ let list = Array.isArray(rows) ? rows.slice() : [];
362
+ const where = region.getAttribute("data-wd-loop-where");
363
+ if (where) {
364
+ const predicate = loopPredicate(where);
365
+ list = list.filter((/** @type {any} */ item) => {
366
+ try {
367
+ return predicate((/** @type {string | null} */ path) => getPath(item, path), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn);
368
+ } catch (error) {
369
+ warn(where, error);
370
+ return false;
371
+ }
372
+ });
373
+ }
374
+ list = pipeline(list, region);
375
+
376
+ // Empty branch: clone the [data-wd-loop-empty] template into the output.
377
+ const emptyTpl = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-empty]"));
378
+ if (!list.length && emptyTpl) {
379
+ if (out.getAttribute("data-wd-empty") !== "1") {
380
+ out.textContent = "";
381
+ for (const child of [...emptyTpl.content.children]) out.appendChild(child.cloneNode(true));
382
+ out.setAttribute("data-wd-empty", "1");
383
+ }
384
+ return;
385
+ }
386
+ if (out.getAttribute("data-wd-empty") === "1") { out.textContent = ""; out.removeAttribute("data-wd-empty"); }
387
+
388
+ /** @type {Map<string, WdRow>} */
389
+ const existing = new Map();
390
+ for (const child of [...out.children]) {
391
+ existing.set(child.getAttribute("data-wd-loop-key") || "", /** @type {WdRow} */ (child));
392
+ }
393
+ /** @type {Map<string, number>} */
394
+ const counts = new Map();
395
+ /** @type {Set<string>} */
396
+ const used = new Set();
397
+ const count = list.length;
398
+ for (let i = 0; i < count; i++) {
399
+ const item = list[i];
400
+ const key = loopKeyOf(item, counts);
401
+ let node = existing.get(key);
402
+ if (!node || used.has(key)) {
403
+ node = /** @type {WdRow} */ (template.content.firstElementChild?.cloneNode(true));
404
+ if (!node) continue;
405
+ node.setAttribute("data-wd-loop-key", key);
406
+ }
407
+ used.add(key);
408
+ fillItem(node, item, { index: i, number: i + 1, first: i === 0, last: i === count - 1, count });
409
+ node.__wdItem = item; /** let per-row actions resolve which row was clicked */
410
+ out.appendChild(node);
411
+ }
412
+ for (const [key, node] of existing) {
413
+ if (!used.has(key)) node.remove();
414
+ }
415
+ }
416
+
338
417
  /**
339
418
  * Synchronously render the whole document from current `state`:
340
419
  * computed → if-regions → keyed loop reconcile → text/input binds.
@@ -348,69 +427,14 @@ function renderNow() {
348
427
  // bindings (data-wd-each-class) are handled per item inside fillItem.
349
428
  for (const el of document.querySelectorAll("[data-wd-class]")) classToggle(el, el.getAttribute("data-wd-class"), undefined);
350
429
 
430
+ // Nested item-relative loops use data-wd-loop-item, so this top-level query
431
+ // skips them; fillItem reconciles those against their enclosing row.
351
432
  for (const region of document.querySelectorAll("[data-wd-loop]")) {
352
433
  const key = region.getAttribute("data-wd-loop");
353
434
  const data = region.getAttribute("data-wd-loop-data");
354
435
  /** Source may be a dotted path (e.g. team.members) read off state via getPath. */
355
436
  const dot = key ? key.indexOf(".") : -1;
356
- const rows = key ? (dot < 0 ? state[key] : getPath(state[key.slice(0, dot)], key.slice(dot + 1))) : (data ? JSON.parse(data) : []);
357
- const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-template]"));
358
- const out = region.querySelector("[data-wd-loop-out]");
359
- if (!template || !out) continue;
360
- let list = Array.isArray(rows) ? rows.slice() : [];
361
- const where = region.getAttribute("data-wd-loop-where");
362
- if (where) {
363
- const predicate = loopPredicate(where);
364
- list = list.filter((/** @type {any} */ item) => {
365
- try {
366
- return predicate((/** @type {string | null} */ path) => getPath(item, path), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn);
367
- } catch (error) {
368
- warn(where, error);
369
- return false;
370
- }
371
- });
372
- }
373
- list = pipeline(list, region);
374
-
375
- // Empty branch: clone the [data-wd-loop-empty] template into the output.
376
- const emptyTpl = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-empty]"));
377
- if (!list.length && emptyTpl) {
378
- if (out.getAttribute("data-wd-empty") !== "1") {
379
- out.textContent = "";
380
- for (const child of [...emptyTpl.content.children]) out.appendChild(child.cloneNode(true));
381
- out.setAttribute("data-wd-empty", "1");
382
- }
383
- continue;
384
- }
385
- if (out.getAttribute("data-wd-empty") === "1") { out.textContent = ""; out.removeAttribute("data-wd-empty"); }
386
-
387
- /** @type {Map<string, WdRow>} */
388
- const existing = new Map();
389
- for (const child of [...out.children]) {
390
- existing.set(child.getAttribute("data-wd-loop-key") || "", /** @type {WdRow} */ (child));
391
- }
392
- /** @type {Map<string, number>} */
393
- const counts = new Map();
394
- /** @type {Set<string>} */
395
- const used = new Set();
396
- const count = list.length;
397
- for (let i = 0; i < count; i++) {
398
- const item = list[i];
399
- const key = loopKeyOf(item, counts);
400
- let node = existing.get(key);
401
- if (!node || used.has(key)) {
402
- node = /** @type {WdRow} */ (template.content.firstElementChild?.cloneNode(true));
403
- if (!node) continue;
404
- node.setAttribute("data-wd-loop-key", key);
405
- }
406
- used.add(key);
407
- fillItem(node, item, { index: i, number: i + 1, first: i === 0, last: i === count - 1, count });
408
- node.__wdItem = item; /** let per-row actions resolve which row was clicked */
409
- out.appendChild(node);
410
- }
411
- for (const [key, node] of existing) {
412
- if (!used.has(key)) node.remove();
413
- }
437
+ reconcile(region, key ? (dot < 0 ? state[key] : getPath(state[key.slice(0, dot)], key.slice(dot + 1))) : (data ? JSON.parse(data) : []));
414
438
  }
415
439
 
416
440
  for (const node of document.querySelectorAll("[data-wd-bind]")) {
package/src/scaffold.js CHANGED
@@ -12,136 +12,164 @@ const darkmownVersion = JSON.parse(
12
12
  */
13
13
  export function initProject(root) {
14
14
  fs.mkdirSync(root, { recursive: true });
15
- writeNew(root, "package.json", JSON.stringify({
16
- name: packageNameFromRoot(root),
17
- private: true,
18
- scripts: {
19
- dev: "darkmown dev",
20
- build: "darkmown build",
21
- preview: "darkmown serve"
22
- },
23
- devDependencies: {
24
- "@zvndev/darkmown": `^${darkmownVersion}`
25
- }
26
- }, null, 2));
27
- writeNew(root, "site/pages/index.wd", [
28
- "---",
29
- "title: My Darkmown site",
30
- "---",
31
- "",
32
- "@include /nav.wd",
33
- "",
34
- "<main>",
35
- "",
36
- "# My Darkmown site",
37
- "",
38
- "Plain Markdown works. Rename to `.wd` when you want directives.",
39
- "",
40
- ":state count = 0",
41
- "",
42
- "Count: { count }",
43
- "",
44
- ":button \"Increment\" -> count++",
45
- "",
46
- ":state todos = [\"Write a page\"]",
47
- "",
48
- "@loop todos into todo",
49
- "- { todo }",
50
- "@endloop",
51
- "",
52
- ":button \"Add\" -> todos += \"Add a loop\"",
53
- "",
54
- "</main>"
55
- ].join("\n"));
56
- writeNew(root, "site/pages/index.skin", [
57
- "tokens",
58
- " ink #172026",
59
- " paper #f7f2e8",
60
- " accent #0f8b8d",
61
- "",
62
- "page",
63
- " margin 0",
64
- " font ui-sans-serif, system-ui, sans-serif",
65
- " color $ink",
66
- " bg $paper",
67
- "",
68
- "main",
69
- " max-width 760px",
70
- " margin 0 auto",
71
- " padding 3rem 1.5rem",
72
- "",
73
- "nav",
74
- " display flex",
75
- " align-items center",
76
- " justify-content space-between",
77
- " gap 1rem",
78
- " max-width 760px",
79
- " margin 0 auto",
80
- " padding 1.25rem 1.5rem",
81
- "",
82
- "nav strong",
83
- " font-size 1.1rem",
84
- "",
85
- "nav .links",
86
- " display flex",
87
- " gap 1.25rem",
88
- "",
89
- "nav a",
90
- " color $ink",
91
- " text-decoration none",
92
- " opacity .8",
93
- "",
94
- "nav a:hover",
95
- " opacity 1",
96
- " color $accent",
97
- "",
98
- "button",
99
- " bg $accent",
100
- " color white",
101
- " border 0",
102
- " padding .75rem 1rem",
103
- " radius 8px"
104
- ].join("\n"));
105
- writeNew(root, "site/_/nav.wd", [
106
- "<nav>",
107
- " <strong>My site</strong>",
108
- " <span class=\"links\">",
109
- " <a href=\"/\">Home</a>",
110
- " <a href=\"/about/\">About</a>",
111
- " </span>",
112
- "</nav>"
113
- ].join("\n"));
114
- writeNew(root, "site/pages/about.md", [
115
- "---",
116
- "title: About",
117
- "---",
118
- "",
119
- "# About",
120
- "",
121
- "This page is plain Markdown (`.md`) — strict CommonMark, zero framework JavaScript.",
122
- "Rename it to `.wd` when you want directives like state, loops, or forms.",
123
- "",
124
- "[Back home](/)"
125
- ].join("\n"));
126
- writeNew(root, "README.md", [
127
- "# My Darkmown site",
128
- "",
129
- "Run `npm install` and `npm run dev` to start the live compiler.",
130
- "",
131
- "## Commands",
132
- "",
133
- "- `npm run dev` — live compiler on http://localhost:5173 with hot reload.",
134
- "- `npm run build` — compile the site to `dist/`.",
135
- "- `npm run preview` — serve the built `dist/` locally.",
136
- "",
137
- "## Layout",
138
- "",
139
- "- Pages live in `site/pages`.",
140
- "- Shared includes live in `site/_`.",
141
- "- Hidden route files start with `.` or `-`.",
142
- "- Colocated `index.skin` and `index.js` attach automatically.",
143
- "- Any other file under `site/pages` (images, fonts, …) copies to `dist/` at its own path."
144
- ].join("\n"));
15
+ writeNew(
16
+ root,
17
+ "package.json",
18
+ JSON.stringify(
19
+ {
20
+ name: packageNameFromRoot(root),
21
+ private: true,
22
+ scripts: {
23
+ dev: "darkmown dev",
24
+ build: "darkmown build",
25
+ preview: "darkmown serve"
26
+ },
27
+ devDependencies: {
28
+ "@zvndev/darkmown": `^${darkmownVersion}`
29
+ }
30
+ },
31
+ null,
32
+ 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")
133
+ );
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
+ );
145
173
  return { root };
146
174
  }
147
175
 
@@ -159,7 +187,6 @@ function writeNew(root, file, content) {
159
187
  fs.writeFileSync(target, `${content}\n`);
160
188
  }
161
189
 
162
-
163
190
  /**
164
191
  * Derive a safe default npm package name from the scaffold target directory.
165
192
  * @param {string} root Absolute target directory.