@zvndev/darkmown 0.19.0 → 1.0.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 (69) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +115 -5
  4. package/docs/spec-alignment.md +16 -3
  5. package/package.json +22 -4
  6. package/site/_/base.skin +372 -0
  7. package/site/_/base.wd +4 -0
  8. package/site/_/footer.wd +14 -0
  9. package/site/_/metrics.json +13 -0
  10. package/site/_/nav.skin +35 -12
  11. package/site/_/nav.wd +8 -10
  12. package/site/_/products.json +10 -0
  13. package/site/pages/app.wd +1 -1
  14. package/site/pages/carousel/index.js +82 -0
  15. package/site/pages/carousel/index.skin +185 -0
  16. package/site/pages/carousel/index.wd +109 -0
  17. package/site/pages/carousel/slide-1.jpg +0 -0
  18. package/site/pages/carousel/slide-2.jpg +0 -0
  19. package/site/pages/carousel/slide-3.jpg +0 -0
  20. package/site/pages/carousel/slide-4.jpg +0 -0
  21. package/site/pages/carousel/slide-5.jpg +0 -0
  22. package/site/pages/compass/index.skin +247 -0
  23. package/site/pages/compass/index.wd +194 -0
  24. package/site/pages/docs/index.skin +48 -28
  25. package/site/pages/docs/index.wd +71 -5
  26. package/site/pages/folio/-style.skin +375 -0
  27. package/site/pages/folio/-style.wd +6 -0
  28. package/site/pages/folio/cart.skin +309 -0
  29. package/site/pages/folio/cart.wd +201 -0
  30. package/site/pages/folio/index.skin +24 -0
  31. package/site/pages/folio/index.wd +167 -0
  32. package/site/pages/forge/index.skin +676 -0
  33. package/site/pages/forge/index.wd +330 -0
  34. package/site/pages/index.skin +135 -144
  35. package/site/pages/index.wd +17 -5
  36. package/site/pages/ledger/index.skin +257 -0
  37. package/site/pages/ledger/index.wd +174 -0
  38. package/site/pages/media/clip-poster.jpg +0 -0
  39. package/site/pages/media/clip.mp4 +0 -0
  40. package/site/pages/media/index.skin +82 -0
  41. package/site/pages/media/index.wd +84 -0
  42. package/site/pages/media/tone.mp3 +0 -0
  43. package/site/pages/pulse/index.skin +434 -0
  44. package/site/pages/pulse/index.wd +178 -0
  45. package/site/pages/showcase/index.skin +130 -0
  46. package/site/pages/showcase/index.wd +168 -0
  47. package/src/builder.js +20 -14
  48. package/src/cli.js +216 -77
  49. package/src/compiler/body.js +457 -0
  50. package/src/compiler/context.js +160 -0
  51. package/src/compiler/directives.js +1345 -0
  52. package/src/compiler/format.js +244 -0
  53. package/src/compiler/frontmatter.js +117 -0
  54. package/src/compiler/includes.js +116 -0
  55. package/src/compiler/index.js +22 -0
  56. package/src/compiler/interpolation.js +223 -0
  57. package/src/compiler/loops.js +715 -0
  58. package/src/compiler/markdown.js +242 -0
  59. package/src/compiler/page.js +267 -0
  60. package/src/compiler/predicates.js +314 -0
  61. package/src/compiler.js +26 -2932
  62. package/src/dev.js +3 -1
  63. package/src/headers.js +8 -4
  64. package/src/index.js +22 -0
  65. package/src/router.js +3 -1
  66. package/src/runtime.js +168 -14
  67. package/src/scaffold.js +158 -131
  68. package/src/skin.js +11 -2
  69. package/site/_/og.png +0 -0
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 CHANGED
@@ -19,6 +19,9 @@
19
19
  * so reactive pages additionally need `script-src 'unsafe-eval'`.
20
20
  * - Inline `<style>` for view transitions — `style-src 'unsafe-inline'`.
21
21
  * - `data:` favicon and remote images — `img-src 'self' data: https:`.
22
+ * - `:video`/`:audio` allow relative or `http(s)` sources — `media-src 'self' https:`.
23
+ * - `:embed` rewrites to exactly the YouTube no-cookie and Vimeo player origins,
24
+ * so `frame-src` pre-authorizes those two hosts (and nothing else).
22
25
  *
23
26
  * Static pages (zero framework JS) still emit inline `<style>` and an inline
24
27
  * `<script type="speculationrules">`, so they keep `'unsafe-inline'`, but they
@@ -42,6 +45,8 @@ const COMMON_CSP_DIRECTIVES = [
42
45
  "default-src 'self'",
43
46
  "style-src 'self' 'unsafe-inline'",
44
47
  "img-src 'self' data: https:",
48
+ "media-src 'self' https:",
49
+ "frame-src https://www.youtube-nocookie.com https://player.vimeo.com",
45
50
  "font-src 'self'",
46
51
  CONNECT_SRC,
47
52
  "object-src 'none'",
@@ -65,10 +70,9 @@ export const REACTIVE_CSP = [
65
70
  * keep `'unsafe-inline'` on script/style.
66
71
  * @type {string}
67
72
  */
68
- export const STATIC_CSP = [
69
- "script-src 'self' 'unsafe-inline'",
70
- ...COMMON_CSP_DIRECTIVES
71
- ].join("; ");
73
+ export const STATIC_CSP = ["script-src 'self' 'unsafe-inline'", ...COMMON_CSP_DIRECTIVES].join(
74
+ "; "
75
+ );
72
76
 
73
77
  /**
74
78
  * Baseline security headers applied to every HTML response, independent of the
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
@@ -119,6 +119,75 @@ function isEmpty(v) {
119
119
  return v == null || (typeof v === "object" && (Array.isArray(v) ? v.length : Object.keys(v).length) === 0);
120
120
  }
121
121
 
122
+ // --- Format pipes -----------------------------------------------------------
123
+ // A compact mirror of src/compiler/format.js: the same formatter math, so a
124
+ // reactive `{ value | money }` re-formats in the browser exactly as the static
125
+ // fold did at build time. tests/format-parity.test.js guards the two against
126
+ // drift. Pure functions of (value, args) — no clock, no DOM — Intl does the work.
127
+
128
+ /** @param {any} v @returns {number} */
129
+ const fmtNum = (v) => { const n = typeof v === "number" ? v : Number.parseFloat(v); return Number.isFinite(n) ? n : 0; };
130
+ /** @param {any} v @returns {any[]} */
131
+ const fmtList = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]);
132
+ /** @param {any} row @param {string} [f] */
133
+ const fmtPick = (row, f) => (f != null && f !== "" ? (row == null ? undefined : row[f]) : row);
134
+ /** @param {any[]} list @param {string} [f] */
135
+ const fmtNums = (list, f) => list.map((r) => fmtNum(fmtPick(r, f)));
136
+ /** @param {any} v @returns {Date} */
137
+ const fmtToDate = (v) => {
138
+ if (v instanceof Date) return v;
139
+ if (typeof v === "number") return new Date(v);
140
+ const s = String(v).trim();
141
+ return /^-?\d+$/.test(s) ? new Date(Number(s)) : new Date(s);
142
+ };
143
+ /** @param {Date} d @param {any} opts @param {any} fb */
144
+ const fmtDate = (d, opts, fb) => (Number.isNaN(d.getTime()) ? String(fb) : new Intl.DateTimeFormat(undefined, opts).format(d));
145
+
146
+ /** @type {Record<string, (value: any, args: any[]) => any>} */
147
+ const FMT = {
148
+ money: (v, [c = "USD", l]) => new Intl.NumberFormat(l || undefined, { style: "currency", currency: String(c || "USD") }).format(fmtNum(v)),
149
+ number: (v, [d]) => new Intl.NumberFormat(undefined, d == null ? {} : { minimumFractionDigits: +d, maximumFractionDigits: +d }).format(fmtNum(v)),
150
+ percent: (v, [d = 0]) => new Intl.NumberFormat(undefined, { style: "percent", minimumFractionDigits: +d, maximumFractionDigits: +d }).format(fmtNum(v)),
151
+ round: (v, [d = 0]) => { const p = 10 ** +d; return Math.round(fmtNum(v) * p) / p; },
152
+ date: (v, [s = "medium"]) => fmtDate(fmtToDate(v), { dateStyle: s }, v),
153
+ time: (v, [s = "short"]) => fmtDate(fmtToDate(v), { timeStyle: s }, v),
154
+ datetime: (v, [ds = "medium", ts = "short"]) => fmtDate(fmtToDate(v), { dateStyle: ds, timeStyle: ts }, v),
155
+ upper: (v) => String(v ?? "").toUpperCase(),
156
+ lower: (v) => String(v ?? "").toLowerCase(),
157
+ capitalize: (v) => { const s = String(v ?? ""); return s ? s[0].toUpperCase() + s.slice(1) : s; },
158
+ truncate: (v, [n = 50, suf = "…"]) => { const s = String(v ?? ""); return s.length > +n ? s.slice(0, +n).trimEnd() + String(suf) : s; },
159
+ trim: (v) => String(v ?? "").trim(),
160
+ pluralize: (v, [sg = "", pl]) => { const n = fmtNum(v); return `${new Intl.NumberFormat().format(n)} ${Math.abs(n) === 1 ? String(sg) : pl != null ? String(pl) : `${sg}s`}`; },
161
+ default: (v, [fb = ""]) => (v == null || v === "" || (Array.isArray(v) && !v.length) ? String(fb) : v),
162
+ sum: (v, [f]) => fmtNums(fmtList(v), f).reduce((a, n) => a + n, 0),
163
+ avg: (v, [f]) => { const l = fmtList(v); return l.length ? FMT.sum(l, [f]) / l.length : 0; },
164
+ min: (v, [f]) => { const l = fmtList(v); return l.length ? Math.min(...fmtNums(l, f)) : 0; },
165
+ max: (v, [f]) => { const l = fmtList(v); return l.length ? Math.max(...fmtNums(l, f)) : 0; },
166
+ count: (v) => fmtList(v).length,
167
+ join: (v, [sep = ", ", f]) => fmtList(v).map((r) => fmtPick(r, f)).join(String(sep))
168
+ };
169
+
170
+ /**
171
+ * Run a node's `data-wd-fmt` pipe chain over a resolved value; identity when the
172
+ * node carries no chain. Shared by text binds, loop-row binds, and meta markers.
173
+ * @param {Element} node
174
+ * @param {any} value
175
+ * @returns {any}
176
+ */
177
+ function applyFmt(node, value) {
178
+ const raw = node.getAttribute("data-wd-fmt");
179
+ if (!raw) return value;
180
+ let stages;
181
+ try { stages = JSON.parse(raw); } catch { return value; }
182
+ let out = value;
183
+ for (const [name, args] of stages) { if (FMT[name]) out = FMT[name](out, args || []); }
184
+ return out;
185
+ }
186
+
187
+ /** Aggregate helper exposed to `:computed` as `A("sum", list, "field")`. */
188
+ const AGG = (/** @type {string} */ name, /** @type {any} */ list, /** @type {string} */ field) =>
189
+ FMT[name] ? FMT[name](list, field == null ? [] : [field]) : undefined;
190
+
122
191
  /**
123
192
  * Shared HTTP→JSON core for `:fetch` and the round-trip `:form`. Sends the
124
193
  * request, throws on non-2xx, and parses the body as JSON (falling back to a
@@ -142,7 +211,7 @@ async function httpJson(url, init) {
142
211
  const computedDefs = [...document.querySelectorAll("[data-wd-computed]")].map((node) => ({
143
212
  key: node.getAttribute("data-wd-computed-key") || "",
144
213
  expr: node.getAttribute("data-wd-computed-expr") || "",
145
- evaluate: new Function("S", `return (${node.getAttribute("data-wd-computed-expr")});`)
214
+ evaluate: new Function("S", "A", `return (${node.getAttribute("data-wd-computed-expr")});`)
146
215
  }));
147
216
 
148
217
  /**
@@ -165,7 +234,7 @@ function recompute() {
165
234
  const read = (key, path) => getPath(state[key], path ?? null);
166
235
  for (const def of computedDefs) {
167
236
  try {
168
- state[def.key] = def.evaluate(read);
237
+ state[def.key] = def.evaluate(read, AGG);
169
238
  } catch (error) {
170
239
  state[def.key] = undefined;
171
240
  warn(def.expr, error);
@@ -269,11 +338,11 @@ function fillItem(node, item, meta = {}) {
269
338
  : [...node.querySelectorAll("[data-wd-each]")];
270
339
  for (const target of targets) {
271
340
  if (skip(target)) continue;
272
- target.textContent = getPath(item, target.getAttribute("data-wd-path")) ?? "";
341
+ target.textContent = applyFmt(target, getPath(item, target.getAttribute("data-wd-path"))) ?? "";
273
342
  }
274
343
  for (const marker of node.querySelectorAll("[data-wd-each-meta]")) {
275
344
  if (skip(marker)) continue;
276
- marker.textContent = meta[marker.getAttribute("data-wd-each-meta") || ""] ?? "";
345
+ marker.textContent = applyFmt(marker, meta[marker.getAttribute("data-wd-each-meta") || ""]) ?? "";
277
346
  }
278
347
  const classNodes = node.matches("[data-wd-each-class]") ? [node, ...node.querySelectorAll("[data-wd-each-class]")] : [...node.querySelectorAll("[data-wd-each-class]")];
279
348
  for (const el of classNodes) { if (!skip(el)) classToggle(el, el.getAttribute("data-wd-each-class"), item); }
@@ -302,15 +371,24 @@ function loopNum(region, name) {
302
371
  * @returns {any[]}
303
372
  */
304
373
  function pipeline(list, region) {
305
- const sort = region.getAttribute("data-wd-loop-sort");
306
- if (sort != null) {
307
- const dir = region.getAttribute("data-wd-loop-sort-dir") === "desc" ? -1 : 1;
308
- list = list.map((value, index) => ({ value, index })).sort((a, b) => {
309
- const av = getPath(a.value, sort || null);
310
- const bv = getPath(b.value, sort || null);
311
- const c = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
312
- return (c || a.index - b.index) * dir;
313
- }).map((w) => w.value);
374
+ const sortAttr = region.getAttribute("data-wd-loop-sort");
375
+ if (sortAttr != null) {
376
+ // `key:<name>` reads the sort field / direction from state (reactive,
377
+ // clickable-header sort); a plain value is the literal `sort by` clause.
378
+ const reactive = sortAttr.startsWith("key:");
379
+ const sort = reactive ? String(state[sortAttr.slice(4)] ?? "") : sortAttr;
380
+ // A reactive sort with no column chosen yet (state is "") leaves the order.
381
+ if (!reactive || sort) {
382
+ let dirAttr = region.getAttribute("data-wd-loop-sort-dir") || "asc";
383
+ if (dirAttr.startsWith("key:")) dirAttr = String(state[dirAttr.slice(4)] ?? "asc");
384
+ const dir = dirAttr === "desc" ? -1 : 1;
385
+ list = list.map((value, index) => ({ value, index })).sort((a, b) => {
386
+ const av = getPath(a.value, sort || null);
387
+ const bv = getPath(b.value, sort || null);
388
+ const c = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
389
+ return (c || a.index - b.index) * dir;
390
+ }).map((w) => w.value);
391
+ }
314
392
  }
315
393
  if (region.hasAttribute("data-wd-loop-reverse")) list.reverse();
316
394
  const off = loopNum(region, "data-wd-loop-offset");
@@ -438,14 +516,29 @@ function renderNow() {
438
516
  }
439
517
 
440
518
  for (const node of document.querySelectorAll("[data-wd-bind]")) {
441
- node.textContent = getPath(state[node.getAttribute("data-wd-bind") || ""], node.getAttribute("data-wd-path")) ?? "";
519
+ node.textContent = applyFmt(node, getPath(state[node.getAttribute("data-wd-bind") || ""], node.getAttribute("data-wd-path"))) ?? "";
442
520
  }
443
521
 
444
522
  for (const input of document.querySelectorAll("[data-wd-bind-input]")) {
445
523
  if (document.activeElement !== input) /** @type {HTMLInputElement} */ (input).value = state[input.getAttribute("data-wd-bind-input") || ""] ?? "";
446
524
  }
447
525
 
526
+ if (themeNodes.length) reflectThemes();
448
527
  runEffects(); // side effects run last, against fully settled state
528
+ if (subscribers.size) notifySubscribers();
529
+ }
530
+
531
+ /** :theme — reflect a store value onto <html data-theme> so a manual light/dark
532
+ * switch layers over the OS preference: `auto`/empty clears the attribute (follow
533
+ * the system via the skin's `tokens dark` media query), anything else forces it. */
534
+ const themeNodes = [...document.querySelectorAll("[data-wd-theme]")];
535
+ function reflectThemes() {
536
+ for (const node of themeNodes) {
537
+ const value = state[node.getAttribute("data-wd-theme") || ""];
538
+ const root = document.documentElement;
539
+ if (value == null || value === "" || value === "auto") root.removeAttribute("data-theme");
540
+ else root.setAttribute("data-theme", String(value));
541
+ }
449
542
  }
450
543
 
451
544
  /** @typedef {{ watch: string, actions: { op: string, target?: string, value?: any }[], last: string }} Effect */
@@ -755,6 +848,52 @@ for (const node of fetchNodes) {
755
848
  }
756
849
  }
757
850
 
851
+ // :every — run actions on a timer, paused while the tab is hidden so background
852
+ // tabs don't poll or animate. Each tick runs the action sequence (no clicked row,
853
+ // like :effect), then saves + renders + checks refetch deps, exactly like a click.
854
+ /** @typedef {{ ms: number, actions: { op: string, target?: string, value?: any }[] }} EveryDef */
855
+ /** @type {EveryDef[]} */
856
+ const everyDefs = [...document.querySelectorAll("script[data-wd-every]")].map((node) => JSON.parse(node.textContent || "{}"));
857
+ /** @type {any[]} */
858
+ let everyTimers = [];
859
+ function startEvery() {
860
+ if (everyTimers.length || document.hidden) return;
861
+ for (const def of everyDefs) {
862
+ if (!def.ms || !def.actions) continue;
863
+ everyTimers.push(setInterval(() => {
864
+ for (const a of def.actions) applyAction(null, a.op, a.target || "", a.value);
865
+ savePersisted();
866
+ render();
867
+ checkRefetch();
868
+ }, def.ms));
869
+ }
870
+ }
871
+ function stopEvery() {
872
+ for (const timer of everyTimers) clearInterval(timer);
873
+ everyTimers = [];
874
+ }
875
+ if (everyDefs.length) {
876
+ document.addEventListener("visibilitychange", () => (document.hidden ? stopEvery() : startEvery()));
877
+ startEvery();
878
+ }
879
+
880
+ // Escape-hatch subscriptions: colocated `.js` "behaviors" (carousels, drag-and-
881
+ // drop, charts, maps) react to state with `wd.subscribe(key, cb)`. Each subscribe
882
+ // primes the callback with the current value, then fires on every settled change.
883
+ /** @typedef {{ cbs: Set<(value: any) => void>, last: string }} SubEntry */
884
+ /** @type {Map<string, SubEntry>} */
885
+ const subscribers = new Map();
886
+ function notifySubscribers() {
887
+ for (const [key, entry] of subscribers) {
888
+ const now = JSON.stringify(state[key] ?? null);
889
+ if (now === entry.last) continue;
890
+ entry.last = now;
891
+ for (const cb of entry.cbs) {
892
+ try { cb(state[key]); } catch (error) { warn(`subscribe ${key}`, error); }
893
+ }
894
+ }
895
+ }
896
+
758
897
  /**
759
898
  * Public escape hatch on `window.wd`. Set `window.wd.debug = true` to log failing
760
899
  * computed/where expressions to the console.
@@ -770,6 +909,21 @@ for (const node of fetchNodes) {
770
909
  savePersisted();
771
910
  render();
772
911
  },
912
+ /**
913
+ * Run `cb(value)` now and on every future change to `state[key]`. Returns an
914
+ * unsubscribe function. The bridge for colocated `.js` behaviors.
915
+ * @param {string} key @param {(value: any) => void} cb
916
+ */
917
+ subscribe: (key, cb) => {
918
+ let entry = subscribers.get(key);
919
+ if (!entry) {
920
+ entry = { cbs: new Set(), last: JSON.stringify(state[key] ?? null) };
921
+ subscribers.set(key, entry);
922
+ }
923
+ entry.cbs.add(cb);
924
+ try { cb(state[key]); } catch (error) { warn(`subscribe ${key}`, error); }
925
+ return () => entry.cbs.delete(cb);
926
+ },
773
927
  render: renderNow
774
928
  };
775
929
 
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.