@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/CHANGELOG.md +19 -0
- package/README.md +61 -2
- package/SECURITY.md +39 -4
- package/docs/spec-alignment.md +15 -1
- package/package.json +4 -3
- package/site/_/nav.wd +1 -0
- package/site/pages/data.wd +1 -0
- package/site/pages/docs/index.wd +9 -0
- package/site/pages/index.wd +1 -0
- package/site/pages/loops.wd +23 -0
- package/site/pages/markdown.md +1 -0
- package/site/pages/reactive.wd +1 -0
- package/site/pages/store.wd +1 -0
- package/site/pages/theme.skin +53 -0
- package/site/pages/theme.wd +58 -0
- package/src/builder.js +50 -0
- package/src/compiler.js +317 -27
- package/src/headers.js +94 -0
- package/src/runtime.js +83 -59
- package/src/skin.js +60 -2
- package/src/statics.js +19 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|