@zvndev/darkmown 0.8.2 → 0.10.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 +21 -0
- package/README.md +16 -10
- package/docs/cli.md +24 -2
- package/docs/spec-alignment.md +4 -4
- package/package.json +21 -4
- package/site/_/roadmap.json +2 -2
- package/site/pages/docs/index.wd +2 -2
- package/site/pages/index.skin +158 -0
- package/site/pages/index.wd +94 -6
- package/src/builder.js +60 -1
- package/src/cli.js +28 -3
- package/src/compiler.js +503 -14
- package/src/config.js +19 -0
- package/src/dev.js +9 -0
- package/src/router.js +26 -0
- package/src/runtime.js +102 -26
- package/src/scaffold.js +32 -1
- 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,17 +55,29 @@ 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"),
|
|
39
|
-
expr: node.getAttribute("data-wd-computed-expr"),
|
|
58
|
+
key: node.getAttribute("data-wd-computed-key") || "",
|
|
59
|
+
expr: node.getAttribute("data-wd-computed-expr") || "",
|
|
40
60
|
evaluate: new Function("S", `return (${node.getAttribute("data-wd-computed-expr")});`)
|
|
41
61
|
}));
|
|
42
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
|
+
*/
|
|
43
69
|
function warn(expr, error) {
|
|
44
|
-
|
|
70
|
+
const wd = /** @type {any} */ (window).wd;
|
|
71
|
+
if (wd && wd.debug) console.warn(`[wd] expression failed: ${expr}`, error);
|
|
45
72
|
}
|
|
46
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Re-evaluate all `:computed` definitions into `state`.
|
|
76
|
+
* @returns {void}
|
|
77
|
+
*/
|
|
47
78
|
function recompute() {
|
|
48
|
-
|
|
79
|
+
/** @param {string} key @param {string | null} [path] */
|
|
80
|
+
const read = (key, path) => getPath(state[key], path ?? null);
|
|
49
81
|
for (const def of computedDefs) {
|
|
50
82
|
try {
|
|
51
83
|
state[def.key] = def.evaluate(read);
|
|
@@ -56,8 +88,14 @@ function recompute() {
|
|
|
56
88
|
}
|
|
57
89
|
}
|
|
58
90
|
|
|
91
|
+
/** @param {unknown} a @param {unknown} b @returns {boolean} */
|
|
59
92
|
const containsFn = (a, b) => String(a ?? "").toLowerCase().includes(String(b ?? "").toLowerCase());
|
|
93
|
+
/** @type {Map<string, Function>} */
|
|
60
94
|
const predicateCache = new Map();
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} body
|
|
97
|
+
* @returns {Function}
|
|
98
|
+
*/
|
|
61
99
|
function loopPredicate(body) {
|
|
62
100
|
let fn = predicateCache.get(body);
|
|
63
101
|
if (!fn) {
|
|
@@ -67,6 +105,12 @@ function loopPredicate(body) {
|
|
|
67
105
|
return fn;
|
|
68
106
|
}
|
|
69
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
|
+
*/
|
|
70
114
|
function loopKeyOf(item, counts) {
|
|
71
115
|
const base =
|
|
72
116
|
item && typeof item === "object"
|
|
@@ -77,13 +121,20 @@ function loopKeyOf(item, counts) {
|
|
|
77
121
|
return seen ? `${base}#${seen}` : base;
|
|
78
122
|
}
|
|
79
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
|
+
*/
|
|
80
130
|
function fillItem(node, item) {
|
|
81
131
|
// querySelectorAll does not descend into <template> content, so this only sees
|
|
82
132
|
// the outermost regions; recursing into each injected branch fills the rest.
|
|
83
133
|
for (const region of node.querySelectorAll("[data-wd-each-if]")) {
|
|
84
134
|
const value = getPath(item, region.getAttribute("data-wd-path"));
|
|
85
135
|
const output = region.querySelector("[data-wd-each-if-out]");
|
|
86
|
-
|
|
136
|
+
if (!output) continue;
|
|
137
|
+
const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector(value ? "template[data-wd-if-true]" : "template[data-wd-if-false]"));
|
|
87
138
|
output.innerHTML = template?.innerHTML || "";
|
|
88
139
|
fillItem(output, item);
|
|
89
140
|
}
|
|
@@ -95,15 +146,21 @@ function fillItem(node, item) {
|
|
|
95
146
|
}
|
|
96
147
|
}
|
|
97
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Synchronously render the whole document from current `state`:
|
|
151
|
+
* computed → if-regions → keyed loop reconcile → text/input binds.
|
|
152
|
+
* @returns {void}
|
|
153
|
+
*/
|
|
98
154
|
function renderNow() {
|
|
99
155
|
recompute();
|
|
100
156
|
for (const node of document.querySelectorAll("[data-wd-if]")) {
|
|
101
|
-
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"));
|
|
102
158
|
const active = String(Boolean(value));
|
|
103
159
|
if (node.getAttribute("data-wd-if-active") === active) continue;
|
|
104
160
|
node.setAttribute("data-wd-if-active", active);
|
|
105
161
|
const output = node.querySelector("[data-wd-if-out]");
|
|
106
|
-
|
|
162
|
+
if (!output) continue;
|
|
163
|
+
const template = /** @type {HTMLTemplateElement | null} */ (node.querySelector(value ? "template[data-wd-true]" : "template[data-wd-false]"));
|
|
107
164
|
output.innerHTML = template?.innerHTML || "";
|
|
108
165
|
}
|
|
109
166
|
|
|
@@ -111,16 +168,16 @@ function renderNow() {
|
|
|
111
168
|
const key = region.getAttribute("data-wd-loop");
|
|
112
169
|
const data = region.getAttribute("data-wd-loop-data");
|
|
113
170
|
const rows = key ? state[key] : (data ? JSON.parse(data) : []);
|
|
114
|
-
const template = region.querySelector("template[data-wd-loop-template]");
|
|
171
|
+
const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-template]"));
|
|
115
172
|
const out = region.querySelector("[data-wd-loop-out]");
|
|
116
173
|
if (!template || !out) continue;
|
|
117
174
|
let list = Array.isArray(rows) ? rows : [];
|
|
118
175
|
const where = region.getAttribute("data-wd-loop-where");
|
|
119
176
|
if (where) {
|
|
120
177
|
const predicate = loopPredicate(where);
|
|
121
|
-
list = list.filter((item) => {
|
|
178
|
+
list = list.filter((/** @type {any} */ item) => {
|
|
122
179
|
try {
|
|
123
|
-
return predicate((path) => getPath(item, path), (k, r) => getPath(state[k], r || ""), containsFn);
|
|
180
|
+
return predicate((/** @type {string | null} */ path) => getPath(item, path), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn);
|
|
124
181
|
} catch (error) {
|
|
125
182
|
warn(where, error);
|
|
126
183
|
return false;
|
|
@@ -128,17 +185,21 @@ function renderNow() {
|
|
|
128
185
|
});
|
|
129
186
|
}
|
|
130
187
|
|
|
188
|
+
/** @type {Map<string, WdRow>} */
|
|
131
189
|
const existing = new Map();
|
|
132
190
|
for (const child of [...out.children]) {
|
|
133
|
-
existing.set(child.getAttribute("data-wd-loop-key"), child);
|
|
191
|
+
existing.set(child.getAttribute("data-wd-loop-key") || "", /** @type {WdRow} */ (child));
|
|
134
192
|
}
|
|
193
|
+
/** @type {Map<string, number>} */
|
|
135
194
|
const counts = new Map();
|
|
195
|
+
/** @type {Set<string>} */
|
|
136
196
|
const used = new Set();
|
|
137
197
|
for (const item of list) {
|
|
138
198
|
const key = loopKeyOf(item, counts);
|
|
139
199
|
let node = existing.get(key);
|
|
140
200
|
if (!node || used.has(key)) {
|
|
141
|
-
node = template.content.firstElementChild
|
|
201
|
+
node = /** @type {WdRow} */ (template.content.firstElementChild?.cloneNode(true));
|
|
202
|
+
if (!node) continue;
|
|
142
203
|
node.setAttribute("data-wd-loop-key", key);
|
|
143
204
|
}
|
|
144
205
|
used.add(key);
|
|
@@ -152,11 +213,11 @@ function renderNow() {
|
|
|
152
213
|
}
|
|
153
214
|
|
|
154
215
|
for (const node of document.querySelectorAll("[data-wd-bind]")) {
|
|
155
|
-
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")) ?? "";
|
|
156
217
|
}
|
|
157
218
|
|
|
158
219
|
for (const input of document.querySelectorAll("[data-wd-bind-input]")) {
|
|
159
|
-
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") || ""] ?? "";
|
|
160
221
|
}
|
|
161
222
|
}
|
|
162
223
|
|
|
@@ -174,26 +235,31 @@ function render() {
|
|
|
174
235
|
}
|
|
175
236
|
|
|
176
237
|
document.addEventListener("input", (event) => {
|
|
177
|
-
const input = event.target
|
|
238
|
+
const input = /** @type {HTMLInputElement | null} */ (/** @type {Element} */ (event.target)?.closest("[data-wd-bind-input]"));
|
|
178
239
|
if (!input) return;
|
|
179
|
-
state[input.getAttribute("data-wd-bind-input")] = input.value;
|
|
240
|
+
state[input.getAttribute("data-wd-bind-input") || ""] = input.value;
|
|
180
241
|
savePersisted();
|
|
181
242
|
render();
|
|
182
243
|
});
|
|
183
244
|
|
|
184
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
|
+
*/
|
|
185
251
|
function clickedRow(el) {
|
|
186
|
-
const row = el.closest("[data-wd-loop-key]");
|
|
252
|
+
const row = /** @type {WdRow | null} */ (el.closest("[data-wd-loop-key]"));
|
|
187
253
|
const region = el.closest("[data-wd-loop]");
|
|
188
254
|
if (!row || !region) return null;
|
|
189
255
|
return { srcKey: region.getAttribute("data-wd-loop"), item: row.__wdItem };
|
|
190
256
|
}
|
|
191
257
|
|
|
192
258
|
document.addEventListener("click", (event) => {
|
|
193
|
-
const action = event.target
|
|
259
|
+
const action = /** @type {Element} */ (event.target)?.closest("[data-wd-action]");
|
|
194
260
|
if (!action) return;
|
|
195
261
|
const op = action.getAttribute("data-wd-action");
|
|
196
|
-
const target = action.getAttribute("data-wd-target");
|
|
262
|
+
const target = action.getAttribute("data-wd-target") || "";
|
|
197
263
|
const rawValue = action.getAttribute("data-wd-value");
|
|
198
264
|
const value = rawValue === null ? undefined : JSON.parse(rawValue);
|
|
199
265
|
|
|
@@ -204,7 +270,7 @@ document.addEventListener("click", (event) => {
|
|
|
204
270
|
if (op === "set") state[target] = value;
|
|
205
271
|
if (op === "remove") {
|
|
206
272
|
const row = clickedRow(action);
|
|
207
|
-
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);
|
|
208
274
|
}
|
|
209
275
|
if (op === "append-row") {
|
|
210
276
|
const row = clickedRow(action);
|
|
@@ -221,10 +287,10 @@ document.addEventListener("click", (event) => {
|
|
|
221
287
|
});
|
|
222
288
|
|
|
223
289
|
document.addEventListener("submit", (event) => {
|
|
224
|
-
const form = event.target
|
|
290
|
+
const form = /** @type {HTMLFormElement | null} */ (/** @type {Element} */ (event.target)?.closest("[data-wd-form]"));
|
|
225
291
|
if (!form) return;
|
|
226
292
|
event.preventDefault();
|
|
227
|
-
const key = form.getAttribute("data-wd-form");
|
|
293
|
+
const key = form.getAttribute("data-wd-form") || "";
|
|
228
294
|
const action = form.getAttribute("action");
|
|
229
295
|
|
|
230
296
|
if (!action) {
|
|
@@ -237,7 +303,7 @@ document.addEventListener("submit", (event) => {
|
|
|
237
303
|
fetch(action, {
|
|
238
304
|
method: form.getAttribute("method") || "post",
|
|
239
305
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
240
|
-
body: new URLSearchParams(new FormData(form)).toString()
|
|
306
|
+
body: new URLSearchParams(/** @type {any} */ (new FormData(form))).toString()
|
|
241
307
|
})
|
|
242
308
|
.then(async (response) => {
|
|
243
309
|
const text = await response.text();
|
|
@@ -259,9 +325,14 @@ document.addEventListener("submit", (event) => {
|
|
|
259
325
|
});
|
|
260
326
|
});
|
|
261
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Kick off a `:fetch` request and write the result (or error) into state.
|
|
330
|
+
* @param {Element} node
|
|
331
|
+
* @returns {void}
|
|
332
|
+
*/
|
|
262
333
|
function startFetch(node) {
|
|
263
|
-
const key = node.getAttribute("data-wd-fetch-key");
|
|
264
|
-
fetch(node.getAttribute("data-wd-fetch-url"))
|
|
334
|
+
const key = node.getAttribute("data-wd-fetch-key") || "";
|
|
335
|
+
fetch(node.getAttribute("data-wd-fetch-url") || "")
|
|
265
336
|
.then((response) => {
|
|
266
337
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
267
338
|
return response.json();
|
|
@@ -290,9 +361,14 @@ for (const node of document.querySelectorAll("[data-wd-fetch]")) {
|
|
|
290
361
|
}
|
|
291
362
|
}
|
|
292
363
|
|
|
293
|
-
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 = {
|
|
294
367
|
state,
|
|
368
|
+
debug: false,
|
|
369
|
+
/** @param {string} key */
|
|
295
370
|
get: (key) => state[key],
|
|
371
|
+
/** @param {string} key @param {any} value */
|
|
296
372
|
set: (key, value) => {
|
|
297
373
|
state[key] = value;
|
|
298
374
|
savePersisted();
|
package/src/scaffold.js
CHANGED
|
@@ -5,12 +5,20 @@ 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({
|
|
16
|
+
name: packageNameFromRoot(root),
|
|
17
|
+
private: true,
|
|
11
18
|
scripts: {
|
|
12
19
|
dev: "darkmown dev",
|
|
13
|
-
build: "darkmown build"
|
|
20
|
+
build: "darkmown build",
|
|
21
|
+
preview: "darkmown serve"
|
|
14
22
|
},
|
|
15
23
|
devDependencies: {
|
|
16
24
|
"@zvndev/darkmown": `^${darkmownVersion}`
|
|
@@ -128,9 +136,32 @@ export function initProject(root) {
|
|
|
128
136
|
return { root };
|
|
129
137
|
}
|
|
130
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Write a file relative to `root` only if it does not already exist.
|
|
141
|
+
* @param {string} root Project root directory.
|
|
142
|
+
* @param {string} file Path relative to `root`.
|
|
143
|
+
* @param {string} content File contents (a trailing newline is added).
|
|
144
|
+
* @returns {void}
|
|
145
|
+
*/
|
|
131
146
|
function writeNew(root, file, content) {
|
|
132
147
|
const target = path.join(root, file);
|
|
133
148
|
if (fs.existsSync(target)) return;
|
|
134
149
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
135
150
|
fs.writeFileSync(target, `${content}\n`);
|
|
136
151
|
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Derive a safe default npm package name from the scaffold target directory.
|
|
156
|
+
* @param {string} root Absolute target directory.
|
|
157
|
+
* @returns {string}
|
|
158
|
+
*/
|
|
159
|
+
function packageNameFromRoot(root) {
|
|
160
|
+
const base = path.basename(root) || "darkmown-site";
|
|
161
|
+
const normalized = base
|
|
162
|
+
.toLowerCase()
|
|
163
|
+
.replace(/^@/, "")
|
|
164
|
+
.replace(/[^a-z0-9._~-]+/g, "-")
|
|
165
|
+
.replace(/^[._-]+|[._-]+$/g, "");
|
|
166
|
+
return normalized || "darkmown-site";
|
|
167
|
+
}
|
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
|
+
};
|