@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +25 -0
- package/README.md +115 -5
- package/docs/spec-alignment.md +16 -3
- package/package.json +22 -4
- package/site/_/base.skin +372 -0
- package/site/_/base.wd +4 -0
- package/site/_/footer.wd +14 -0
- package/site/_/metrics.json +13 -0
- package/site/_/nav.skin +35 -12
- package/site/_/nav.wd +8 -10
- package/site/_/products.json +10 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/carousel/index.js +82 -0
- package/site/pages/carousel/index.skin +185 -0
- package/site/pages/carousel/index.wd +109 -0
- package/site/pages/carousel/slide-1.jpg +0 -0
- package/site/pages/carousel/slide-2.jpg +0 -0
- package/site/pages/carousel/slide-3.jpg +0 -0
- package/site/pages/carousel/slide-4.jpg +0 -0
- package/site/pages/carousel/slide-5.jpg +0 -0
- package/site/pages/compass/index.skin +247 -0
- package/site/pages/compass/index.wd +194 -0
- package/site/pages/docs/index.skin +48 -28
- package/site/pages/docs/index.wd +71 -5
- package/site/pages/folio/-style.skin +375 -0
- package/site/pages/folio/-style.wd +6 -0
- package/site/pages/folio/cart.skin +309 -0
- package/site/pages/folio/cart.wd +201 -0
- package/site/pages/folio/index.skin +24 -0
- package/site/pages/folio/index.wd +167 -0
- package/site/pages/forge/index.skin +676 -0
- package/site/pages/forge/index.wd +330 -0
- package/site/pages/index.skin +135 -144
- package/site/pages/index.wd +17 -5
- package/site/pages/ledger/index.skin +257 -0
- package/site/pages/ledger/index.wd +174 -0
- package/site/pages/media/clip-poster.jpg +0 -0
- package/site/pages/media/clip.mp4 +0 -0
- package/site/pages/media/index.skin +82 -0
- package/site/pages/media/index.wd +84 -0
- package/site/pages/media/tone.mp3 +0 -0
- package/site/pages/pulse/index.skin +434 -0
- package/site/pages/pulse/index.wd +178 -0
- package/site/pages/showcase/index.skin +130 -0
- package/site/pages/showcase/index.wd +168 -0
- package/src/builder.js +20 -14
- package/src/cli.js +216 -77
- package/src/compiler/body.js +457 -0
- package/src/compiler/context.js +160 -0
- package/src/compiler/directives.js +1345 -0
- package/src/compiler/format.js +244 -0
- package/src/compiler/frontmatter.js +117 -0
- package/src/compiler/includes.js +116 -0
- package/src/compiler/index.js +22 -0
- package/src/compiler/interpolation.js +223 -0
- package/src/compiler/loops.js +715 -0
- package/src/compiler/markdown.js +242 -0
- package/src/compiler/page.js +267 -0
- package/src/compiler/predicates.js +314 -0
- package/src/compiler.js +26 -2932
- package/src/dev.js +3 -1
- package/src/headers.js +8 -4
- package/src/index.js +22 -0
- package/src/router.js +3 -1
- package/src/runtime.js +168 -14
- package/src/scaffold.js +158 -131
- package/src/skin.js +11 -2
- 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>")
|
|
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
|
-
"
|
|
70
|
-
|
|
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(
|
|
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
|
|
306
|
-
if (
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"",
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
"
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
"",
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"",
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
""
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
"",
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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.
|