@zvndev/darkmown 0.8.1 → 0.9.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 +24 -2
- package/README.md +15 -5
- package/docs/cli.md +2 -0
- package/docs/spec-alignment.md +8 -4
- package/package.json +17 -3
- package/site/_/nav.skin +7 -0
- package/site/pages/docs/index.wd +1 -1
- package/site/pages/index.skin +165 -6
- package/site/pages/index.wd +95 -6
- package/src/builder.js +60 -1
- package/src/cli.js +14 -1
- package/src/compiler.js +530 -21
- package/src/config.js +19 -0
- package/src/dev.js +9 -0
- package/src/router.js +26 -0
- package/src/runtime.js +129 -29
- package/src/scaffold.js +12 -0
- package/src/skin.js +31 -3
- package/src/statics.js +20 -0
- package/types/builder.d.ts +39 -0
- package/types/compiler.d.ts +184 -0
- package/types/config.d.ts +45 -0
- package/types/dev.d.ts +13 -0
- package/types/index.d.ts +33 -0
- package/types/router.d.ts +39 -0
- package/types/scaffold.d.ts +8 -0
- package/types/skin.d.ts +21 -0
- package/types/statics.d.ts +23 -0
- package/docs/okf-integration.md +0 -120
package/src/config.js
CHANGED
|
@@ -5,6 +5,20 @@ export const shelfDir = "site/_";
|
|
|
5
5
|
export const distDir = "dist";
|
|
6
6
|
export const pageExtensions = new Set([".md", ".wd"]);
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Resolved project paths used across the build pipeline.
|
|
10
|
+
* @typedef {object} Paths
|
|
11
|
+
* @property {string} cwd Project working directory.
|
|
12
|
+
* @property {string} routesRoot Absolute path to `site/pages`.
|
|
13
|
+
* @property {string} shelfRoot Absolute path to the include shelf `site/_`.
|
|
14
|
+
* @property {string} distRoot Absolute path to the build output `dist`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build the absolute project paths for a given working directory.
|
|
19
|
+
* @param {string} [cwd] Project working directory (defaults to `process.cwd()`).
|
|
20
|
+
* @returns {Paths}
|
|
21
|
+
*/
|
|
8
22
|
export function createPaths(cwd = process.cwd()) {
|
|
9
23
|
return {
|
|
10
24
|
cwd,
|
|
@@ -14,6 +28,11 @@ export function createPaths(cwd = process.cwd()) {
|
|
|
14
28
|
};
|
|
15
29
|
}
|
|
16
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Whether a file or directory name is hidden from routing (starts with `.`, `-`, or `_`).
|
|
33
|
+
* @param {string} name
|
|
34
|
+
* @returns {boolean}
|
|
35
|
+
*/
|
|
17
36
|
export function isHiddenName(name) {
|
|
18
37
|
return name.startsWith(".") || name.startsWith("-") || name.startsWith("_");
|
|
19
38
|
}
|
package/src/dev.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
export const devClientPath = "/__wd/dev-client.js";
|
|
2
2
|
export const devEventsPath = "/__wd/dev-events";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Inject the dev-client `<script>` before `</body>` (or append it if absent).
|
|
6
|
+
* @param {string} html Page HTML to augment.
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
4
9
|
export function injectDevClient(html) {
|
|
5
10
|
const script = `<script type="module" src="${devClientPath}"></script>`;
|
|
6
11
|
return html.includes("</body>") ? html.replace("</body>", `${script}\n</body>`) : `${html}\n${script}`;
|
|
7
12
|
}
|
|
8
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The dev-client browser script that opens the SSE channel for live reload.
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
9
18
|
export function devClientScript() {
|
|
10
19
|
return `
|
|
11
20
|
const source = new EventSource("${devEventsPath}");
|
package/src/router.js
CHANGED
|
@@ -2,10 +2,24 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { isHiddenName, pageExtensions } from "./config.js";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* A discovered page route: the source file and its public route path.
|
|
7
|
+
* @typedef {object} Route
|
|
8
|
+
* @property {string} file Absolute path to the source `.md`/`.wd` file.
|
|
9
|
+
* @property {string} route Public route path (always trailing-slashed, e.g. `/about/`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Walk `routesRoot` and collect page routes, sorted and de-duplicated.
|
|
14
|
+
* @param {string} routesRoot Absolute path to `site/pages`.
|
|
15
|
+
* @returns {Route[]}
|
|
16
|
+
*/
|
|
5
17
|
export function discoverRoutes(routesRoot) {
|
|
6
18
|
if (!fs.existsSync(routesRoot)) return [];
|
|
19
|
+
/** @type {Route[]} */
|
|
7
20
|
const routes = [];
|
|
8
21
|
|
|
22
|
+
/** @param {string} dir */
|
|
9
23
|
function walk(dir) {
|
|
10
24
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
11
25
|
if (isHiddenName(entry.name)) continue;
|
|
@@ -36,6 +50,12 @@ export function discoverRoutes(routesRoot) {
|
|
|
36
50
|
return routes.sort((a, b) => a.route.localeCompare(b.route));
|
|
37
51
|
}
|
|
38
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Derive a public route path from a source file path.
|
|
55
|
+
* @param {string} routesRoot Absolute path to `site/pages`.
|
|
56
|
+
* @param {string} file Absolute path to the source file.
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
39
59
|
export function routeFromFile(routesRoot, file) {
|
|
40
60
|
const ext = path.extname(file);
|
|
41
61
|
const rel = path.relative(routesRoot, file).slice(0, -ext.length);
|
|
@@ -45,6 +65,12 @@ export function routeFromFile(routesRoot, file) {
|
|
|
45
65
|
return route === "/" ? "/" : `${route}/`;
|
|
46
66
|
}
|
|
47
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Map a public route to the `index.html` output path under `distRoot`.
|
|
70
|
+
* @param {string} distRoot Absolute path to the build output directory.
|
|
71
|
+
* @param {string} route Public route path.
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
48
74
|
export function outputPathForRoute(distRoot, route) {
|
|
49
75
|
const clean = route === "/" ? "" : route.replace(/^\/|\/$/g, "");
|
|
50
76
|
return path.join(distRoot, clean, "index.html");
|
package/src/runtime.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Darkmown's tiny reactive runtime. Browser-only: it reads `data-wd-*` attributes
|
|
3
|
+
* emitted by the compiler, hydrates declared state, and re-renders on changes.
|
|
4
|
+
* @module runtime
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A reconciled loop-row element. Carries its source row on `__wdItem` so per-row
|
|
9
|
+
* button actions can resolve which row was clicked.
|
|
10
|
+
* @typedef {Element & { __wdItem?: any }} WdRow
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** @type {Record<string, any>} */
|
|
1
14
|
const state = {};
|
|
15
|
+
/** @type {Set<string>} */
|
|
2
16
|
const persistKeys = new Set();
|
|
3
17
|
|
|
4
18
|
for (const script of document.querySelectorAll("script[data-wd-state]")) {
|
|
@@ -23,6 +37,12 @@ function savePersisted() {
|
|
|
23
37
|
}
|
|
24
38
|
}
|
|
25
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Safely read a dotted path off a value, rejecting prototype-pollution segments.
|
|
42
|
+
* @param {any} value
|
|
43
|
+
* @param {string | null} path
|
|
44
|
+
* @returns {any}
|
|
45
|
+
*/
|
|
26
46
|
function getPath(value, path) {
|
|
27
47
|
if (!path) return value;
|
|
28
48
|
let current = value;
|
|
@@ -35,23 +55,47 @@ function getPath(value, path) {
|
|
|
35
55
|
}
|
|
36
56
|
|
|
37
57
|
const computedDefs = [...document.querySelectorAll("[data-wd-computed]")].map((node) => ({
|
|
38
|
-
key: node.getAttribute("data-wd-computed-key"),
|
|
58
|
+
key: node.getAttribute("data-wd-computed-key") || "",
|
|
59
|
+
expr: node.getAttribute("data-wd-computed-expr") || "",
|
|
39
60
|
evaluate: new Function("S", `return (${node.getAttribute("data-wd-computed-expr")});`)
|
|
40
61
|
}));
|
|
41
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Log an expression failure when `window.wd.debug` is enabled.
|
|
65
|
+
* @param {string} expr
|
|
66
|
+
* @param {unknown} error
|
|
67
|
+
* @returns {void}
|
|
68
|
+
*/
|
|
69
|
+
function warn(expr, error) {
|
|
70
|
+
const wd = /** @type {any} */ (window).wd;
|
|
71
|
+
if (wd && wd.debug) console.warn(`[wd] expression failed: ${expr}`, error);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Re-evaluate all `:computed` definitions into `state`.
|
|
76
|
+
* @returns {void}
|
|
77
|
+
*/
|
|
42
78
|
function recompute() {
|
|
43
|
-
|
|
79
|
+
/** @param {string} key @param {string | null} [path] */
|
|
80
|
+
const read = (key, path) => getPath(state[key], path ?? null);
|
|
44
81
|
for (const def of computedDefs) {
|
|
45
82
|
try {
|
|
46
83
|
state[def.key] = def.evaluate(read);
|
|
47
|
-
} catch {
|
|
84
|
+
} catch (error) {
|
|
48
85
|
state[def.key] = undefined;
|
|
86
|
+
warn(def.expr, error);
|
|
49
87
|
}
|
|
50
88
|
}
|
|
51
89
|
}
|
|
52
90
|
|
|
91
|
+
/** @param {unknown} a @param {unknown} b @returns {boolean} */
|
|
53
92
|
const containsFn = (a, b) => String(a ?? "").toLowerCase().includes(String(b ?? "").toLowerCase());
|
|
93
|
+
/** @type {Map<string, Function>} */
|
|
54
94
|
const predicateCache = new Map();
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} body
|
|
97
|
+
* @returns {Function}
|
|
98
|
+
*/
|
|
55
99
|
function loopPredicate(body) {
|
|
56
100
|
let fn = predicateCache.get(body);
|
|
57
101
|
if (!fn) {
|
|
@@ -61,6 +105,12 @@ function loopPredicate(body) {
|
|
|
61
105
|
return fn;
|
|
62
106
|
}
|
|
63
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Stable per-render key for a loop row, disambiguating duplicates with `#n`.
|
|
110
|
+
* @param {any} item
|
|
111
|
+
* @param {Map<string, number>} counts
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
64
114
|
function loopKeyOf(item, counts) {
|
|
65
115
|
const base =
|
|
66
116
|
item && typeof item === "object"
|
|
@@ -71,13 +121,20 @@ function loopKeyOf(item, counts) {
|
|
|
71
121
|
return seen ? `${base}#${seen}` : base;
|
|
72
122
|
}
|
|
73
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Fill a cloned loop row node: resolve per-item `:if` regions and text binds.
|
|
126
|
+
* @param {Element} node
|
|
127
|
+
* @param {any} item
|
|
128
|
+
* @returns {void}
|
|
129
|
+
*/
|
|
74
130
|
function fillItem(node, item) {
|
|
75
131
|
// querySelectorAll does not descend into <template> content, so this only sees
|
|
76
132
|
// the outermost regions; recursing into each injected branch fills the rest.
|
|
77
133
|
for (const region of node.querySelectorAll("[data-wd-each-if]")) {
|
|
78
134
|
const value = getPath(item, region.getAttribute("data-wd-path"));
|
|
79
135
|
const output = region.querySelector("[data-wd-each-if-out]");
|
|
80
|
-
|
|
136
|
+
if (!output) continue;
|
|
137
|
+
const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector(value ? "template[data-wd-if-true]" : "template[data-wd-if-false]"));
|
|
81
138
|
output.innerHTML = template?.innerHTML || "";
|
|
82
139
|
fillItem(output, item);
|
|
83
140
|
}
|
|
@@ -89,15 +146,21 @@ function fillItem(node, item) {
|
|
|
89
146
|
}
|
|
90
147
|
}
|
|
91
148
|
|
|
92
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Synchronously render the whole document from current `state`:
|
|
151
|
+
* computed → if-regions → keyed loop reconcile → text/input binds.
|
|
152
|
+
* @returns {void}
|
|
153
|
+
*/
|
|
154
|
+
function renderNow() {
|
|
93
155
|
recompute();
|
|
94
156
|
for (const node of document.querySelectorAll("[data-wd-if]")) {
|
|
95
|
-
const value = getPath(state[node.getAttribute("data-wd-if")], node.getAttribute("data-wd-path"));
|
|
157
|
+
const value = getPath(state[node.getAttribute("data-wd-if") || ""], node.getAttribute("data-wd-path"));
|
|
96
158
|
const active = String(Boolean(value));
|
|
97
159
|
if (node.getAttribute("data-wd-if-active") === active) continue;
|
|
98
160
|
node.setAttribute("data-wd-if-active", active);
|
|
99
161
|
const output = node.querySelector("[data-wd-if-out]");
|
|
100
|
-
|
|
162
|
+
if (!output) continue;
|
|
163
|
+
const template = /** @type {HTMLTemplateElement | null} */ (node.querySelector(value ? "template[data-wd-true]" : "template[data-wd-false]"));
|
|
101
164
|
output.innerHTML = template?.innerHTML || "";
|
|
102
165
|
}
|
|
103
166
|
|
|
@@ -105,29 +168,38 @@ function render() {
|
|
|
105
168
|
const key = region.getAttribute("data-wd-loop");
|
|
106
169
|
const data = region.getAttribute("data-wd-loop-data");
|
|
107
170
|
const rows = key ? state[key] : (data ? JSON.parse(data) : []);
|
|
108
|
-
const template = region.querySelector("template[data-wd-loop-template]");
|
|
171
|
+
const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-template]"));
|
|
109
172
|
const out = region.querySelector("[data-wd-loop-out]");
|
|
110
173
|
if (!template || !out) continue;
|
|
111
174
|
let list = Array.isArray(rows) ? rows : [];
|
|
112
175
|
const where = region.getAttribute("data-wd-loop-where");
|
|
113
176
|
if (where) {
|
|
114
177
|
const predicate = loopPredicate(where);
|
|
115
|
-
list = list.filter((item) =>
|
|
116
|
-
|
|
117
|
-
|
|
178
|
+
list = list.filter((/** @type {any} */ item) => {
|
|
179
|
+
try {
|
|
180
|
+
return predicate((/** @type {string | null} */ path) => getPath(item, path), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
warn(where, error);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
118
186
|
}
|
|
119
187
|
|
|
188
|
+
/** @type {Map<string, WdRow>} */
|
|
120
189
|
const existing = new Map();
|
|
121
190
|
for (const child of [...out.children]) {
|
|
122
|
-
existing.set(child.getAttribute("data-wd-loop-key"), child);
|
|
191
|
+
existing.set(child.getAttribute("data-wd-loop-key") || "", /** @type {WdRow} */ (child));
|
|
123
192
|
}
|
|
193
|
+
/** @type {Map<string, number>} */
|
|
124
194
|
const counts = new Map();
|
|
195
|
+
/** @type {Set<string>} */
|
|
125
196
|
const used = new Set();
|
|
126
197
|
for (const item of list) {
|
|
127
198
|
const key = loopKeyOf(item, counts);
|
|
128
199
|
let node = existing.get(key);
|
|
129
200
|
if (!node || used.has(key)) {
|
|
130
|
-
node = template.content.firstElementChild
|
|
201
|
+
node = /** @type {WdRow} */ (template.content.firstElementChild?.cloneNode(true));
|
|
202
|
+
if (!node) continue;
|
|
131
203
|
node.setAttribute("data-wd-loop-key", key);
|
|
132
204
|
}
|
|
133
205
|
used.add(key);
|
|
@@ -141,35 +213,53 @@ function render() {
|
|
|
141
213
|
}
|
|
142
214
|
|
|
143
215
|
for (const node of document.querySelectorAll("[data-wd-bind]")) {
|
|
144
|
-
node.textContent = getPath(state[node.getAttribute("data-wd-bind")], node.getAttribute("data-wd-path")) ?? "";
|
|
216
|
+
node.textContent = getPath(state[node.getAttribute("data-wd-bind") || ""], node.getAttribute("data-wd-path")) ?? "";
|
|
145
217
|
}
|
|
146
218
|
|
|
147
219
|
for (const input of document.querySelectorAll("[data-wd-bind-input]")) {
|
|
148
|
-
if (document.activeElement !== input) input.value = state[input.getAttribute("data-wd-bind-input")] ?? "";
|
|
220
|
+
if (document.activeElement !== input) /** @type {HTMLInputElement} */ (input).value = state[input.getAttribute("data-wd-bind-input") || ""] ?? "";
|
|
149
221
|
}
|
|
150
222
|
}
|
|
151
223
|
|
|
224
|
+
// Coalesce rapid state mutations into one render on the next tick. Each scheduled
|
|
225
|
+
// pass always reads the latest state, so the final update is never dropped.
|
|
226
|
+
const schedule = typeof requestAnimationFrame === "function" ? requestAnimationFrame : queueMicrotask;
|
|
227
|
+
let scheduled = false;
|
|
228
|
+
function render() {
|
|
229
|
+
if (scheduled) return;
|
|
230
|
+
scheduled = true;
|
|
231
|
+
schedule(() => {
|
|
232
|
+
scheduled = false;
|
|
233
|
+
renderNow();
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
152
237
|
document.addEventListener("input", (event) => {
|
|
153
|
-
const input = event.target
|
|
238
|
+
const input = /** @type {HTMLInputElement | null} */ (/** @type {Element} */ (event.target)?.closest("[data-wd-bind-input]"));
|
|
154
239
|
if (!input) return;
|
|
155
|
-
state[input.getAttribute("data-wd-bind-input")] = input.value;
|
|
240
|
+
state[input.getAttribute("data-wd-bind-input") || ""] = input.value;
|
|
156
241
|
savePersisted();
|
|
157
242
|
render();
|
|
158
243
|
});
|
|
159
244
|
|
|
160
245
|
// The clicked button's row: the nearest reconciled loop node carries its item.
|
|
246
|
+
/**
|
|
247
|
+
* Resolve the loop source and row item for a clicked element, if inside a loop.
|
|
248
|
+
* @param {Element} el
|
|
249
|
+
* @returns {{ srcKey: string | null, item: any } | null}
|
|
250
|
+
*/
|
|
161
251
|
function clickedRow(el) {
|
|
162
|
-
const row = el.closest("[data-wd-loop-key]");
|
|
252
|
+
const row = /** @type {WdRow | null} */ (el.closest("[data-wd-loop-key]"));
|
|
163
253
|
const region = el.closest("[data-wd-loop]");
|
|
164
254
|
if (!row || !region) return null;
|
|
165
255
|
return { srcKey: region.getAttribute("data-wd-loop"), item: row.__wdItem };
|
|
166
256
|
}
|
|
167
257
|
|
|
168
258
|
document.addEventListener("click", (event) => {
|
|
169
|
-
const action = event.target
|
|
259
|
+
const action = /** @type {Element} */ (event.target)?.closest("[data-wd-action]");
|
|
170
260
|
if (!action) return;
|
|
171
261
|
const op = action.getAttribute("data-wd-action");
|
|
172
|
-
const target = action.getAttribute("data-wd-target");
|
|
262
|
+
const target = action.getAttribute("data-wd-target") || "";
|
|
173
263
|
const rawValue = action.getAttribute("data-wd-value");
|
|
174
264
|
const value = rawValue === null ? undefined : JSON.parse(rawValue);
|
|
175
265
|
|
|
@@ -180,7 +270,7 @@ document.addEventListener("click", (event) => {
|
|
|
180
270
|
if (op === "set") state[target] = value;
|
|
181
271
|
if (op === "remove") {
|
|
182
272
|
const row = clickedRow(action);
|
|
183
|
-
if (row && row.srcKey) state[row.srcKey] = (Array.isArray(state[row.srcKey]) ? state[row.srcKey] : []).filter((x) => x !== row.item);
|
|
273
|
+
if (row && row.srcKey) state[row.srcKey] = (Array.isArray(state[row.srcKey]) ? state[row.srcKey] : []).filter((/** @type {any} */ x) => x !== row.item);
|
|
184
274
|
}
|
|
185
275
|
if (op === "append-row") {
|
|
186
276
|
const row = clickedRow(action);
|
|
@@ -197,10 +287,10 @@ document.addEventListener("click", (event) => {
|
|
|
197
287
|
});
|
|
198
288
|
|
|
199
289
|
document.addEventListener("submit", (event) => {
|
|
200
|
-
const form = event.target
|
|
290
|
+
const form = /** @type {HTMLFormElement | null} */ (/** @type {Element} */ (event.target)?.closest("[data-wd-form]"));
|
|
201
291
|
if (!form) return;
|
|
202
292
|
event.preventDefault();
|
|
203
|
-
const key = form.getAttribute("data-wd-form");
|
|
293
|
+
const key = form.getAttribute("data-wd-form") || "";
|
|
204
294
|
const action = form.getAttribute("action");
|
|
205
295
|
|
|
206
296
|
if (!action) {
|
|
@@ -213,7 +303,7 @@ document.addEventListener("submit", (event) => {
|
|
|
213
303
|
fetch(action, {
|
|
214
304
|
method: form.getAttribute("method") || "post",
|
|
215
305
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
216
|
-
body: new URLSearchParams(new FormData(form)).toString()
|
|
306
|
+
body: new URLSearchParams(/** @type {any} */ (new FormData(form))).toString()
|
|
217
307
|
})
|
|
218
308
|
.then(async (response) => {
|
|
219
309
|
const text = await response.text();
|
|
@@ -235,9 +325,14 @@ document.addEventListener("submit", (event) => {
|
|
|
235
325
|
});
|
|
236
326
|
});
|
|
237
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Kick off a `:fetch` request and write the result (or error) into state.
|
|
330
|
+
* @param {Element} node
|
|
331
|
+
* @returns {void}
|
|
332
|
+
*/
|
|
238
333
|
function startFetch(node) {
|
|
239
|
-
const key = node.getAttribute("data-wd-fetch-key");
|
|
240
|
-
fetch(node.getAttribute("data-wd-fetch-url"))
|
|
334
|
+
const key = node.getAttribute("data-wd-fetch-key") || "";
|
|
335
|
+
fetch(node.getAttribute("data-wd-fetch-url") || "")
|
|
241
336
|
.then((response) => {
|
|
242
337
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
243
338
|
return response.json();
|
|
@@ -266,15 +361,20 @@ for (const node of document.querySelectorAll("[data-wd-fetch]")) {
|
|
|
266
361
|
}
|
|
267
362
|
}
|
|
268
363
|
|
|
269
|
-
window.wd =
|
|
364
|
+
// Public escape hatch on `window.wd`. Set `window.wd.debug = true` to log failing
|
|
365
|
+
// computed/where expressions to the console.
|
|
366
|
+
/** @type {any} */ (window).wd = {
|
|
270
367
|
state,
|
|
368
|
+
debug: false,
|
|
369
|
+
/** @param {string} key */
|
|
271
370
|
get: (key) => state[key],
|
|
371
|
+
/** @param {string} key @param {any} value */
|
|
272
372
|
set: (key, value) => {
|
|
273
373
|
state[key] = value;
|
|
274
374
|
savePersisted();
|
|
275
375
|
render();
|
|
276
376
|
},
|
|
277
|
-
render
|
|
377
|
+
render: renderNow
|
|
278
378
|
};
|
|
279
379
|
|
|
280
|
-
|
|
380
|
+
renderNow();
|
package/src/scaffold.js
CHANGED
|
@@ -5,6 +5,11 @@ const darkmownVersion = JSON.parse(
|
|
|
5
5
|
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
6
6
|
).version;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Scaffold a new Darkmown project into `root`, creating files that don't exist.
|
|
10
|
+
* @param {string} root Absolute path to the target project directory.
|
|
11
|
+
* @returns {{ root: string }}
|
|
12
|
+
*/
|
|
8
13
|
export function initProject(root) {
|
|
9
14
|
fs.mkdirSync(root, { recursive: true });
|
|
10
15
|
writeNew(root, "package.json", JSON.stringify({
|
|
@@ -128,6 +133,13 @@ export function initProject(root) {
|
|
|
128
133
|
return { root };
|
|
129
134
|
}
|
|
130
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Write a file relative to `root` only if it does not already exist.
|
|
138
|
+
* @param {string} root Project root directory.
|
|
139
|
+
* @param {string} file Path relative to `root`.
|
|
140
|
+
* @param {string} content File contents (a trailing newline is added).
|
|
141
|
+
* @returns {void}
|
|
142
|
+
*/
|
|
131
143
|
function writeNew(root, file, content) {
|
|
132
144
|
const target = path.join(root, file);
|
|
133
145
|
if (fs.existsSync(target)) return;
|
package/src/skin.js
CHANGED
|
@@ -8,11 +8,29 @@ const aliases = new Map([
|
|
|
8
8
|
// `font` is aliased to font-family for the common `font <stack>` case, but the
|
|
9
9
|
// real CSS `font` shorthand (`font 16px/1.4 system-ui`) must pass through. If
|
|
10
10
|
// the value looks like a shorthand (leads with a size or contains `/`), keep it.
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} prop
|
|
13
|
+
* @param {string[]} rest
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
11
16
|
function resolveProp(prop, rest) {
|
|
12
17
|
if (prop === "font" && (/^[\d.]/.test(rest[0] || "") || rest.join(" ").includes("/"))) return "font";
|
|
13
18
|
return aliases.get(prop) || prop;
|
|
14
19
|
}
|
|
15
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A nesting frame in the indentation stack.
|
|
23
|
+
* @typedef {object} SkinFrame
|
|
24
|
+
* @property {number} indent
|
|
25
|
+
* @property {string | null} selector
|
|
26
|
+
* @property {string} [media]
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Compile indentation-based `.skin` source into CSS.
|
|
31
|
+
* @param {string} source
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
16
34
|
export function compileSkin(source) {
|
|
17
35
|
const lines = source
|
|
18
36
|
.replace(/\r\n?/g, "\n")
|
|
@@ -26,11 +44,15 @@ export function compileSkin(source) {
|
|
|
26
44
|
// digit is real content; punctuation-only lines are skipped.
|
|
27
45
|
return /[A-Za-z0-9]/.test(t);
|
|
28
46
|
})
|
|
29
|
-
.map((raw) => ({ indent: raw.match(/^\s*/)[0].length, text: raw.trim() }));
|
|
47
|
+
.map((raw) => ({ indent: (raw.match(/^\s*/)?.[0] ?? "").length, text: raw.trim() }));
|
|
30
48
|
|
|
49
|
+
/** @type {SkinFrame[]} */
|
|
31
50
|
const stack = [];
|
|
51
|
+
/** @type {string[]} */
|
|
32
52
|
const css = [];
|
|
53
|
+
/** @type {number | null} */
|
|
33
54
|
let tokenIndent = null;
|
|
55
|
+
/** @type {string[]} */
|
|
34
56
|
const rootVars = [];
|
|
35
57
|
|
|
36
58
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -51,7 +73,7 @@ export function compileSkin(source) {
|
|
|
51
73
|
}
|
|
52
74
|
|
|
53
75
|
if (tokenIndent !== null && indent <= tokenIndent) tokenIndent = null;
|
|
54
|
-
while (stack.length && stack.at(-1)
|
|
76
|
+
while (stack.length && (stack.at(-1)?.indent ?? -1) >= indent) stack.pop();
|
|
55
77
|
|
|
56
78
|
if (opensBlock) {
|
|
57
79
|
const parent = stack.at(-1)?.selector;
|
|
@@ -78,12 +100,18 @@ export function compileSkin(source) {
|
|
|
78
100
|
return css.join("\n");
|
|
79
101
|
}
|
|
80
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Combine a selector with its parent (handles `&` nesting and comma lists).
|
|
105
|
+
* @param {string} text
|
|
106
|
+
* @param {string | null | undefined} parent
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
81
109
|
function normalizeSelector(text, parent) {
|
|
82
110
|
const selector = text === "page" ? "body" : text;
|
|
83
111
|
if (!parent || parent === ":root") return selector;
|
|
84
112
|
return selector
|
|
85
113
|
.split(",")
|
|
86
|
-
.map((part) => {
|
|
114
|
+
.map((/** @type {string} */ part) => {
|
|
87
115
|
const clean = part.trim();
|
|
88
116
|
return clean.startsWith("&") ? clean.replace("&", parent) : `${parent} ${clean}`;
|
|
89
117
|
})
|
package/src/statics.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Serve a built file for a request URL out of `distRoot`, or a 404 page.
|
|
7
|
+
* @param {string} distRoot Absolute path to the build output directory.
|
|
8
|
+
* @param {string} url Request URL (may include a query string).
|
|
9
|
+
* @param {http.ServerResponse} res Response to write to.
|
|
10
|
+
* @returns {void}
|
|
11
|
+
*/
|
|
4
12
|
export function serve(distRoot, url, res) {
|
|
5
13
|
const file = resolvePublicFile(distRoot, url || "/");
|
|
6
14
|
if (!file || !fs.existsSync(file)) {
|
|
@@ -12,6 +20,13 @@ export function serve(distRoot, url, res) {
|
|
|
12
20
|
fs.createReadStream(file).pipe(res);
|
|
13
21
|
}
|
|
14
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a request URL to an absolute file inside `distRoot`, guarding against
|
|
25
|
+
* path traversal. Returns null when the resolved path escapes the root.
|
|
26
|
+
* @param {string} distRoot Absolute path to the build output directory.
|
|
27
|
+
* @param {string} url Request URL (may include a query string).
|
|
28
|
+
* @returns {string | null}
|
|
29
|
+
*/
|
|
15
30
|
export function resolvePublicFile(distRoot, url) {
|
|
16
31
|
const cleanUrl = decodeURIComponent(url.split("?")[0]);
|
|
17
32
|
const base = cleanUrl.startsWith("/__wd/")
|
|
@@ -23,6 +38,11 @@ export function resolvePublicFile(distRoot, url) {
|
|
|
23
38
|
return requested;
|
|
24
39
|
}
|
|
25
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Map a file path to a response content-type, defaulting to `text/html`.
|
|
43
|
+
* @param {string} file File path or name.
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
26
46
|
export function contentType(file) {
|
|
27
47
|
if (file.endsWith(".css")) return "text/css";
|
|
28
48
|
if (file.endsWith(".js")) return "text/javascript";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile the whole site under `cwd` into `dist`, returning the route manifest.
|
|
3
|
+
* @param {string} [cwd] Project working directory (defaults to `process.cwd()`).
|
|
4
|
+
* @returns {{ routes: RouteManifestEntry[], distRoot: string }}
|
|
5
|
+
*/
|
|
6
|
+
export function buildSite(cwd?: string): {
|
|
7
|
+
routes: RouteManifestEntry[];
|
|
8
|
+
distRoot: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Strip JSDoc `/** ... *\/` blocks from the runtime before it ships.
|
|
12
|
+
* The source keeps full type annotations (for `checkJs` + `.d.ts`), but the
|
|
13
|
+
* browser never needs to download them — this keeps the shipped runtime lean
|
|
14
|
+
* and the gzip budget honest (measured against what users actually receive).
|
|
15
|
+
* Only `/** ... *\/` blocks are removed; code and string literals are untouched.
|
|
16
|
+
* @param {string} source
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function stripRuntimeComments(source: string): string;
|
|
20
|
+
export type Paths = import("./config.js").Paths;
|
|
21
|
+
export type Assets = import("./compiler.js").Assets;
|
|
22
|
+
/**
|
|
23
|
+
* A built route entry written to `dist/routes.json`.
|
|
24
|
+
*/
|
|
25
|
+
export type RouteManifestEntry = {
|
|
26
|
+
/**
|
|
27
|
+
* Public route path.
|
|
28
|
+
*/
|
|
29
|
+
route: string;
|
|
30
|
+
/**
|
|
31
|
+
* Source file path, relative to cwd, POSIX-separated.
|
|
32
|
+
*/
|
|
33
|
+
file: string;
|
|
34
|
+
assets: {
|
|
35
|
+
skins: string[];
|
|
36
|
+
scripts: string[];
|
|
37
|
+
runtime: boolean;
|
|
38
|
+
};
|
|
39
|
+
};
|