@zvndev/darkmown 0.17.0 → 0.19.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/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/skin.js CHANGED
@@ -53,7 +53,16 @@ export function compileSkin(source) {
53
53
  /** @type {number | null} */
54
54
  let tokenIndent = null;
55
55
  /** @type {string[]} */
56
+ let tokenVars = [];
57
+ /** @type {string[]} */
56
58
  const rootVars = [];
59
+ /** @type {string[]} */
60
+ const darkVars = [];
61
+ // Manual-toggle variants: `tokens [data-theme=dark]` collects custom
62
+ // properties under an attribute-scoped `:root` so a `:store theme` toggle can
63
+ // flip them with no JS beyond writing the attribute.
64
+ /** @type {Map<string, string[]>} */
65
+ const attrVars = new Map();
57
66
 
58
67
  for (let i = 0; i < lines.length; i++) {
59
68
  const { indent, text } = lines[i];
@@ -61,14 +70,41 @@ export function compileSkin(source) {
61
70
  // the next line is indented deeper. Everything else is a declaration.
62
71
  const opensBlock = i + 1 < lines.length && lines[i + 1].indent > indent;
63
72
 
64
- if (text === "tokens" && opensBlock) {
73
+ // `tokens` opens a design-token block emitting `:root { --name: value }`.
74
+ // An optional modifier picks a variant: `tokens dark` collects into a
75
+ // `prefers-color-scheme: dark` override (OS-driven), while a bracketed
76
+ // `tokens [data-theme=dark]` scopes the override to a `:root` attribute for
77
+ // manual toggling. Both reuse the same `--name` properties.
78
+ // Capture the whole modifier (`.+`, not `\S+`) so a malformed bracketed
79
+ // modifier with internal spaces (`[data-theme=dark extra]`) is still routed
80
+ // to normalizeAttr's null fail-safe rather than slipping through as a bogus
81
+ // `tokens …` selector.
82
+ const tokenMatch = /^tokens(?:\s+(.+))?$/.exec(text);
83
+ if (tokenMatch && opensBlock) {
65
84
  tokenIndent = indent;
85
+ const modifier = tokenMatch[1];
86
+ if (!modifier) {
87
+ tokenVars = rootVars;
88
+ } else if (modifier === "dark") {
89
+ tokenVars = darkVars;
90
+ } else if (modifier.startsWith("[") && modifier.endsWith("]")) {
91
+ const attr = normalizeAttr(modifier);
92
+ if (attr) {
93
+ const selector = `:root${attr}`;
94
+ tokenVars = attrVars.get(selector) ?? [];
95
+ attrVars.set(selector, tokenVars);
96
+ } else {
97
+ tokenVars = rootVars; // malformed `[…]` modifier → fall back to default :root tokens
98
+ }
99
+ } else {
100
+ tokenVars = rootVars;
101
+ }
66
102
  continue;
67
103
  }
68
104
 
69
105
  if (tokenIndent !== null && indent > tokenIndent) {
70
106
  const [name, ...rest] = text.split(/\s+/);
71
- rootVars.push(` --${name}: ${rest.join(" ")};`);
107
+ tokenVars.push(` --${name}: ${rest.join(" ")};`);
72
108
  continue;
73
109
  }
74
110
 
@@ -96,10 +132,32 @@ export function compileSkin(source) {
96
132
  css.push(media ? `${media} { ${rule} }` : rule);
97
133
  }
98
134
 
135
+ // Prepend tokens ahead of selector rules. `unshift` reverses insertion order,
136
+ // so push the overrides first to land them after the `:root` defaults.
137
+ for (const [selector, vars] of attrVars) {
138
+ if (vars.length) css.unshift(`${selector} {\n${vars.join("\n")}\n}`);
139
+ }
140
+ if (darkVars.length) {
141
+ css.unshift(`@media (prefers-color-scheme: dark) {\n:root {\n${darkVars.join("\n")}\n}\n}`);
142
+ }
99
143
  if (rootVars.length) css.unshift(`:root {\n${rootVars.join("\n")}\n}`);
100
144
  return css.join("\n");
101
145
  }
102
146
 
147
+ /**
148
+ * Normalize a bracketed token modifier into a valid attribute selector,
149
+ * quoting the value so `[data-theme=dark]` becomes `[data-theme="dark"]`.
150
+ * Returns null for a malformed modifier (`[]`, `[a=b c]`, injection-shaped
151
+ * input) so the caller can fall back to default `:root` tokens instead of
152
+ * emitting a junk selector.
153
+ * @param {string} bracketed
154
+ * @returns {string | null}
155
+ */
156
+ function normalizeAttr(bracketed) {
157
+ const m = bracketed.match(/^\[\s*([^\]=\s]+)\s*=\s*"?([^\]"]*)"?\s*\]$/);
158
+ return m ? `[${m[1]}="${m[2]}"]` : null;
159
+ }
160
+
103
161
  /**
104
162
  * Combine a selector with its parent (handles `&` nesting and comma lists).
105
163
  * @param {string} text
package/src/statics.js CHANGED
@@ -1,6 +1,19 @@
1
1
  import fs from "node:fs";
2
2
  import http from "node:http";
3
3
  import path from "node:path";
4
+ import { BASE_SECURITY_HEADERS, REACTIVE_CSP } from "./headers.js";
5
+
6
+ /**
7
+ * Security headers for an HTML response from the local server. The relaxed
8
+ * (reactive) CSP is a superset that satisfies both static and reactive pages,
9
+ * so the runtime server uses it for every HTML response; the build-time
10
+ * outputs (`vercel.json`, `dist/_headers`) tighten static routes per-path.
11
+ * @type {Record<string, string>}
12
+ */
13
+ const HTML_SECURITY_HEADERS = {
14
+ ...BASE_SECURITY_HEADERS,
15
+ "Content-Security-Policy": REACTIVE_CSP
16
+ };
4
17
 
5
18
  /**
6
19
  * Serve a built file for a request URL out of `distRoot`, or a 404 page.
@@ -12,7 +25,7 @@ import path from "node:path";
12
25
  export function serve(distRoot, url, res) {
13
26
  const file = resolvePublicFile(distRoot, url || "/");
14
27
  if (!file || !fs.existsSync(file)) {
15
- res.writeHead(404, { "content-type": "text/html; charset=utf-8" });
28
+ res.writeHead(404, { "content-type": "text/html; charset=utf-8", ...HTML_SECURITY_HEADERS });
16
29
  const notFound = path.join(distRoot, "404.html");
17
30
  if (fs.existsSync(notFound)) {
18
31
  res.end(fs.readFileSync(notFound));
@@ -21,7 +34,11 @@ export function serve(distRoot, url, res) {
21
34
  res.end("<h1>Not found</h1><p>This route is hidden or has not been created.</p>");
22
35
  return;
23
36
  }
24
- res.writeHead(200, { "content-type": contentType(file) });
37
+ const type = contentType(file);
38
+ const headers = type.startsWith("text/html")
39
+ ? { "content-type": type, ...HTML_SECURITY_HEADERS }
40
+ : { "content-type": type };
41
+ res.writeHead(200, headers);
25
42
  fs.createReadStream(file).pipe(res);
26
43
  }
27
44